--- a/src/eric7/PipInterface/PipPackagesWidget.py Thu Dec 12 11:42:04 2024 +0100 +++ b/src/eric7/PipInterface/PipPackagesWidget.py Sat Dec 14 13:03:11 2024 +0100 @@ -9,16 +9,12 @@ import contextlib import enum -import html.parser import os -import textwrap from packaging.specifiers import InvalidSpecifier, SpecifierSet from PyQt6.QtCore import Qt, QUrl, QUrlQuery, pyqtSlot -from PyQt6.QtGui import QIcon -from PyQt6.QtNetwork import QNetworkReply, QNetworkRequest +from PyQt6.QtGui import QDesktopServices, QIcon from PyQt6.QtWidgets import ( - QAbstractItemView, QDialog, QHeaderView, QMenu, @@ -37,118 +33,6 @@ from .Ui_PipPackagesWidget import Ui_PipPackagesWidget -class PypiSearchResultsParser(html.parser.HTMLParser): - """ - Class implementing the parser for the PyPI search result page. - """ - - ClassPrefix = "package-snippet__" - - def __init__(self, data): - """ - Constructor - - @param data data to be parsed - @type str - """ - super().__init__() - self.__results = [] - self.__activeClass = None - self.feed(data) - - def __getClass(self, attrs): - """ - Private method to extract the class attribute out of the list of - attributes. - - @param attrs list of tag attributes as (name, value) tuples - @type list of tuple of (str, str) - @return value of the 'class' attribute or None - @rtype str - """ - for name, value in attrs: - if name == "class": - return value - - return None - - def __getDate(self, attrs): - """ - Private method to extract the datetime attribute out of the list of - attributes and process it. - - @param attrs list of tag attributes as (name, value) tuples - @type list of tuple of (str, str) - @return value of the 'class' attribute or None - @rtype str - """ - for name, value in attrs: - if name == "datetime": - return value.split("T")[0] - - return None - - def handle_starttag(self, tag, attrs): - """ - Public method to process the start tag. - - @param tag tag name (all lowercase) - @type str - @param attrs list of tag attributes as (name, value) tuples - @type list of tuple of (str, str) - """ - if tag == "a" and self.__getClass(attrs) == "package-snippet": - self.__results.append({}) - - if tag in ("span", "p"): - tagClass = self.__getClass(attrs) - if tagClass in ( - "package-snippet__name", - "package-snippet__description", - "package-snippet__version", - "package-snippet__released", - "package-snippet__created", - ): - self.__activeClass = tagClass - else: - self.__activeClass = None - elif tag == "time": - attributeName = self.__activeClass.replace(self.ClassPrefix, "") - self.__results[-1][attributeName] = self.__getDate(attrs) - self.__activeClass = None - else: - self.__activeClass = None - - def handle_data(self, data): - """ - Public method process arbitrary data. - - @param data data to be processed - @type str - """ - if self.__activeClass is not None: - attributeName = self.__activeClass.replace(self.ClassPrefix, "") - self.__results[-1][attributeName] = data - - def handle_endtag(self, _tag): - """ - Public method to process the end tag. - - @param _tag tag name (all lowercase) (unused) - @type str - """ - self.__activeClass = None - - def getResults(self): - """ - Public method to get the extracted search results. - - @return extracted result data - @rtype list of dict - """ - return self.__results - - class PipPackageInformationMode(enum.Enum): """ Class defining the show information process modes. @@ -203,17 +87,12 @@ self.pipMenuButton.setShowMenuInside(True) self.refreshButton.setIcon(EricPixmapCache.getIcon("reload")) + self.installButton.setIcon(EricPixmapCache.getIcon("plus")) self.upgradeButton.setIcon(EricPixmapCache.getIcon("1uparrow")) self.upgradeAllButton.setIcon(EricPixmapCache.getIcon("2uparrow")) self.uninstallButton.setIcon(EricPixmapCache.getIcon("minus")) self.showPackageDetailsButton.setIcon(EricPixmapCache.getIcon("info")) - self.searchToggleButton_1.setIcon(EricPixmapCache.getIcon("find")) - self.searchToggleButton_2.setIcon(EricPixmapCache.getIcon("find")) - self.searchButton.setIcon(EricPixmapCache.getIcon("findNext")) - self.searchMoreButton.setIcon(EricPixmapCache.getIcon("plus")) - self.installButton.setIcon(EricPixmapCache.getIcon("plus")) - self.installUserSiteButton.setIcon(EricPixmapCache.getIcon("addUser")) - self.showDetailsButton.setIcon(EricPixmapCache.getIcon("info")) + self.searchButton.setIcon(EricPixmapCache.getIcon("find")) self.cleanupButton.setIcon(EricPixmapCache.getIcon("clear")) self.refreshDependenciesButton.setIcon(EricPixmapCache.getIcon("reload")) @@ -263,13 +142,14 @@ self.__packageDetailsDialog = None + self.installButton.clicked.connect(self.__installPackages) + self.__initPipMenu() self.__populateEnvironments() self.__updateActionButtons() self.__updateDepActionButtons() self.statusLabel.hide() - self.searchWidget.hide() self.__lastSearchPage = 0 self.__queryName = [] @@ -426,6 +306,7 @@ Private method to set the state of the action buttons. """ if self.__isPipAvailable(): + self.installButton.setEnabled(True) self.upgradeButton.setEnabled(bool(self.__selectedUpdateableItems())) self.uninstallButton.setEnabled(bool(self.packagesList.selectedItems())) self.upgradeAllButton.setEnabled(bool(self.__allUpdateableItems())) @@ -434,6 +315,7 @@ ) self.cleanupButton.setEnabled(True) else: + self.installButton.setEnabled(False) self.upgradeButton.setEnabled(False) self.uninstallButton.setEnabled(False) self.upgradeAllButton.setEnabled(False) @@ -491,9 +373,6 @@ else: self.__updateActionButtons() - self.__updateSearchActionButtons() - self.__updateSearchButton() - self.__updateSearchMoreButton(False) def __updateOutdatedInfo(self, outdatedPackages): """ @@ -515,9 +394,6 @@ ) self.__updateActionButtons() - self.__updateSearchActionButtons() - self.__updateSearchButton() - self.__updateSearchMoreButton(False) self.statusLabel.hide() @@ -534,12 +410,8 @@ self.environmentPathLabel.setPath( self.__pip.getVirtualenvInterpreter(name) ) - self.searchNameEdit.setEnabled(True) else: self.environmentPathLabel.setPath("") - self.searchNameEdit.clear() - self.searchNameEdit.setEnabled(False) - self.searchResultList.clear() if self.__packageDetailsDialog is not None: self.__packageDetailsDialog.close() @@ -549,7 +421,6 @@ self.__refreshPackagesList() self.__selectedEnvironment = name - ##self.cleanupButton.setEnabled(bool(name)) self.__updateActionButtons() @pyqtSlot() @@ -864,294 +735,32 @@ self.tr("Cleanup Environment"), self.tr( "Some leftover package directories could not been removed." - " Delete them manually."), + " Delete them manually." + ), ) - ####################################################################### - ## Search widget related methods below - ####################################################################### - - def __updateSearchActionButtons(self): - """ - Private method to update the action button states of the search widget. - """ - 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( - 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.searchNameEdit.text()) and self.__isPipAvailable() - ) - - def __updateSearchMoreButton(self, enable): - """ - Private method to update the state of the search more button. - - @param enable flag indicating the desired enable state - @type bool - """ - self.searchMoreButton.setEnabled( - enable and bool(self.searchNameEdit.text()) and self.__isPipAvailable() - ) - - @pyqtSlot(bool) - def on_searchToggleButton_1_toggled(self, checked): - """ - Private slot to toggle the search widget. - - @param checked state of the search widget button - @type bool - """ - self.searchWidget.setVisible(checked) - self.searchToggleButton_2.setChecked(checked) - - if checked: - self.searchNameEdit.setFocus(Qt.FocusReason.OtherFocusReason) - self.searchNameEdit.selectAll() - - self.__updateSearchActionButtons() - self.__updateSearchButton() - self.__updateSearchMoreButton(False) - - @pyqtSlot(bool) - def on_searchToggleButton_2_toggled(self, checked): - """ - Private slot to toggle the search widget. - - @param checked state of the search widget button - @type bool - """ - self.searchToggleButton_1.setChecked(checked) - - @pyqtSlot(str) - def on_searchNameEdit_textChanged(self, _txt): - """ - Private slot handling a change of the search term. - - @param _txt search term (unused) - @type str - """ - self.__updateSearchButton() - - @pyqtSlot() - def on_searchNameEdit_returnPressed(self): - """ - Private slot initiating a search via a press of the Return key. - """ - if bool(self.searchNameEdit.text()) and self.__isPipAvailable(): - self.__searchFirst() - @pyqtSlot() def on_searchButton_clicked(self): """ - Private slot handling a press of the search button. + Private slot to open a web browser for package searching. """ - self.__searchFirst() + url = QUrl(self.__pip.getIndexUrlSearch()) - @pyqtSlot() - def on_searchMoreButton_clicked(self): - """ - Private slot handling a press of the search more button. - """ - self.__search(self.__lastSearchPage + 1) + searchTerm = self.searchEdit.text().strip() + if searchTerm: + searchTerm = bytes(QUrl.toPercentEncoding(searchTerm)).decode() + urlQuery = QUrlQuery() + urlQuery.addQueryItem("q", searchTerm) + url.setQuery(urlQuery) + + QDesktopServices.openUrl(url) @pyqtSlot() - def on_searchResultList_itemSelectionChanged(self): - """ - Private slot handling changes of the search result selection. - """ - self.__updateSearchActionButtons() - - def __searchFirst(self): - """ - Private method to perform the search for packages. - """ - self.searchResultList.clear() - self.searchInfoLabel.clear() - - self.__updateSearchMoreButton(False) - - self.__search() - - def __search(self, page=1): - """ - Private method to perform the search by calling the PyPI search URL. - - @param page search page to retrieve (defaults to 1) - @type int (optional) - """ - self.__lastSearchPage = page - - self.searchButton.setEnabled(False) - - searchTerm = self.searchNameEdit.text().strip() - searchTerm = bytes(QUrl.toPercentEncoding(searchTerm)).decode() - urlQuery = QUrlQuery() - urlQuery.addQueryItem("q", searchTerm) - urlQuery.addQueryItem("page", str(page)) - url = QUrl(self.__pip.getIndexUrlSearch()) - url.setQuery(urlQuery) - - request = QNetworkRequest(QUrl(url)) - request.setAttribute( - QNetworkRequest.Attribute.CacheLoadControlAttribute, - QNetworkRequest.CacheLoadControl.AlwaysNetwork, - ) - reply = self.__pip.getNetworkAccessManager().get(request) - reply.finished.connect(lambda: self.__searchResponse(reply)) - self.__replies.append(reply) - - def __searchResponse(self, reply): - """ - Private method to extract the search result data from the response. - - @param reply reference to the reply object containing the data - @type QNetworkReply + def on_searchEdit_returnPressed(self): """ - if reply in self.__replies: - self.__replies.remove(reply) - - urlQuery = QUrlQuery(reply.url()) - searchTerm = urlQuery.queryItemValue("q") - - if reply.error() != QNetworkReply.NetworkError.NoError: - EricMessageBox.warning( - None, - self.tr("Search PyPI"), - self.tr( - "<p>Received an error while searching for <b>{0}</b>.</p>" - "<p>Error: {1}</p>" - ).format(searchTerm, reply.errorString()), - ) - reply.deleteLater() - return - - data = bytes(reply.readAll()).decode() - reply.deleteLater() - - results = PypiSearchResultsParser(data).getResults() - if results: - # PyPI returns max. 20 entries per page - if len(results) < 20: - msg = self.tr( - "%n package(s) found.", - "", - (self.__lastSearchPage - 1) * 20 + len(results), - ) - self.__updateSearchMoreButton(False) - else: - msg = self.tr("Showing first {0} packages found.").format( - self.__lastSearchPage * 20 - ) - self.__updateSearchMoreButton(True) - self.searchInfoLabel.setText(msg) - lastItem = self.searchResultList.topLevelItem( - self.searchResultList.topLevelItemCount() - 1 - ) - else: - self.__updateSearchMoreButton(False) - if self.__lastSearchPage == 1: - EricMessageBox.warning( - self, - self.tr("Search PyPI"), - self.tr("""<p>There were no results for <b>{0}</b>.</p>""").format( - searchTerm - ), - ) - self.searchInfoLabel.setText( - self.tr("""<p>There were no results for <b>{0}</b>.</p>""").format( - searchTerm - ) - ) - else: - EricMessageBox.warning( - self, - self.tr("Search PyPI"), - self.tr( - """<p>There were no more results for <b>{0}</b>.</p>""" - ).format(searchTerm), - ) - lastItem = None - - wrapper = textwrap.TextWrapper(width=80) - for result in results: - try: - description = "\n".join( - [ - wrapper.fill(line) - for line in result["description"].strip().splitlines() - ] - ) - except KeyError: - description = "" - date = result["released"] if "released" in result else result["created"] - itm = QTreeWidgetItem( - self.searchResultList, - [ - result["name"].strip(), - result["version"], - date.strip(), - description, - ], - ) - itm.setData(0, self.SearchVersionRole, result["version"]) - - if lastItem: - self.searchResultList.scrollToItem( - lastItem, QAbstractItemView.ScrollHint.PositionAtTop - ) - - header = self.searchResultList.header() - header.setStretchLastSection(False) - header.resizeSections(QHeaderView.ResizeMode.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 to handle the press of the Return key in the search line edit. """ - Private slot performing the search finishing actions. - """ - self.__updateSearchActionButtons() - self.__updateSearchButton() - - self.searchNameEdit.setFocus(Qt.FocusReason.OtherFocusReason) - - @pyqtSlot() - def on_installButton_clicked(self): - """ - Private slot to handle pressing the Install button.. - """ - packages = [ - itm.text(0).strip() for itm in self.searchResultList.selectedItems() - ] - self.executeInstallPackages(packages) - - @pyqtSlot() - def on_installUserSiteButton_clicked(self): - """ - Private slot to handle pressing the Install to User-Site button.. - """ - packages = [ - itm.text(0).strip() for itm in self.searchResultList.selectedItems() - ] - self.executeInstallPackages(packages, userSite=True) + self.on_searchButton_clicked() def executeInstallPackages(self, packages, userSite=False): """ @@ -1167,42 +776,6 @@ self.__pip.installPackages(packages, venvName=venvName, userSite=userSite) self.on_refreshButton_clicked() - @pyqtSlot() - def on_showDetailsButton_clicked(self): - """ - Private slot to handle pressing the Show Details button. - """ - self.__showSearchedDetails() - - @pyqtSlot(QTreeWidgetItem, int) - def on_searchResultList_itemActivated(self, item, column): - """ - Private slot reacting on an search result item activation. - - @param item reference to the activated item - @type QTreeWidgetItem - @param column activated column - @type int - """ - self.__showSearchedDetails(item) - - def __showSearchedDetails(self, item=None): - """ - Private slot to show details about the selected search result package. - - @param item reference to the search result item to show details for - @type QTreeWidgetItem - """ - self.showDetailsButton.setEnabled(False) - - if not item: - item = self.searchResultList.selectedItems()[0] - - packageVersion = item.data(0, self.SearchVersionRole) - packageName = item.text(0) - - self.__showPackageDetails(packageName, packageVersion, installable=True) - def __showPackageDetails( self, packageName,