1# -*- coding: utf-8 -*-
2
3# Copyright (c) 2019 - 2021 Detlev Offenbach <detlev@die-offenbachs.de>
4#
5
6"""
7Module implementing the pip packages management widget.
8"""
9
10import textwrap
11import os
12import html.parser
13import contextlib
14
15from PyQt5.QtCore import pyqtSlot, Qt, QUrl, QUrlQuery
16from PyQt5.QtNetwork import QNetworkReply, QNetworkRequest
17from PyQt5.QtWidgets import (
18    QWidget, QToolButton, QApplication, QHeaderView, QTreeWidgetItem,
19    QMenu, QDialog
20)
21
22from E5Gui.E5Application import e5App
23from E5Gui import E5MessageBox
24from E5Gui.E5OverrideCursor import E5OverrideCursor
25
26from .Ui_PipPackagesWidget import Ui_PipPackagesWidget
27
28import UI.PixmapCache
29import Globals
30import Preferences
31
32
33class PypiSearchResultsParser(html.parser.HTMLParser):
34    """
35    Class implementing the parser for the PyPI search result page.
36    """
37    ClassPrefix = "package-snippet__"
38
39    def __init__(self, data):
40        """
41        Constructor
42
43        @param data data to be parsed
44        @type str
45        """
46        super().__init__()
47        self.__results = []
48        self.__activeClass = None
49        self.feed(data)
50
51    def __getClass(self, attrs):
52        """
53        Private method to extract the class attribute out of the list of
54        attributes.
55
56        @param attrs list of tag attributes as (name, value) tuples
57        @type list of tuple of (str, str)
58        @return value of the 'class' attribute or None
59        @rtype str
60        """
61        for name, value in attrs:
62            if name == "class":
63                return value
64
65        return None
66
67    def __getDate(self, attrs):
68        """
69        Private method to extract the datetime attribute out of the list of
70        attributes and process it.
71
72        @param attrs list of tag attributes as (name, value) tuples
73        @type list of tuple of (str, str)
74        @return value of the 'class' attribute or None
75        @rtype str
76        """
77        for name, value in attrs:
78            if name == "datetime":
79                return value.split("T")[0]
80
81        return None
82
83    def handle_starttag(self, tag, attrs):
84        """
85        Public method to process the start tag.
86
87        @param tag tag name (all lowercase)
88        @type str
89        @param attrs list of tag attributes as (name, value) tuples
90        @type list of tuple of (str, str)
91        """
92        if tag == "a" and self.__getClass(attrs) == "package-snippet":
93            self.__results.append({})
94
95        if tag in ("span", "p"):
96            tagClass = self.__getClass(attrs)
97            if tagClass in (
98                "package-snippet__name", "package-snippet__description",
99                "package-snippet__version", "package-snippet__released",
100            ):
101                self.__activeClass = tagClass
102            else:
103                self.__activeClass = None
104        elif tag == "time":
105            attributeName = self.__activeClass.replace(self.ClassPrefix, "")
106            self.__results[-1][attributeName] = self.__getDate(attrs)
107            self.__activeClass = None
108        else:
109            self.__activeClass = None
110
111    def handle_data(self, data):
112        """
113        Public method process arbitrary data.
114
115        @param data data to be processed
116        @type str
117        """
118        if self.__activeClass is not None:
119            attributeName = self.__activeClass.replace(self.ClassPrefix, "")
120            self.__results[-1][attributeName] = data
121
122    def handle_endtag(self, tag):
123        """
124        Public method to process the end tag.
125
126        @param tag tag name (all lowercase)
127        @type str
128        """
129        self.__activeClass = None
130
131    def getResults(self):
132        """
133        Public method to get the extracted search results.
134
135        @return extracted result data
136        @rtype list of dict
137        """
138        return self.__results
139
140
141class PipPackagesWidget(QWidget, Ui_PipPackagesWidget):
142    """
143    Class implementing the pip packages management widget.
144    """
145    ShowProcessGeneralMode = 0
146    ShowProcessClassifiersMode = 1
147    ShowProcessEntryPointsMode = 2
148    ShowProcessFilesListMode = 3
149
150    SearchVersionRole = Qt.ItemDataRole.UserRole + 1
151
152    def __init__(self, pip, parent=None):
153        """
154        Constructor
155
156        @param pip reference to the global pip interface
157        @type Pip
158        @param parent reference to the parent widget
159        @type QWidget
160        """
161        super().__init__(parent)
162        self.setupUi(self)
163
164        self.pipMenuButton.setObjectName(
165            "pip_supermenu_button")
166        self.pipMenuButton.setIcon(UI.PixmapCache.getIcon("superMenu"))
167        self.pipMenuButton.setToolTip(self.tr("pip Menu"))
168        self.pipMenuButton.setPopupMode(
169            QToolButton.ToolButtonPopupMode.InstantPopup)
170        self.pipMenuButton.setToolButtonStyle(
171            Qt.ToolButtonStyle.ToolButtonIconOnly)
172        self.pipMenuButton.setFocusPolicy(Qt.FocusPolicy.NoFocus)
173        self.pipMenuButton.setAutoRaise(True)
174        self.pipMenuButton.setShowMenuInside(True)
175
176        self.refreshButton.setIcon(UI.PixmapCache.getIcon("reload"))
177        self.upgradeButton.setIcon(UI.PixmapCache.getIcon("1uparrow"))
178        self.upgradeAllButton.setIcon(UI.PixmapCache.getIcon("2uparrow"))
179        self.uninstallButton.setIcon(UI.PixmapCache.getIcon("minus"))
180        self.showPackageDetailsButton.setIcon(UI.PixmapCache.getIcon("info"))
181        self.searchToggleButton.setIcon(UI.PixmapCache.getIcon("find"))
182        self.searchButton.setIcon(UI.PixmapCache.getIcon("findNext"))
183        self.installButton.setIcon(UI.PixmapCache.getIcon("plus"))
184        self.installUserSiteButton.setIcon(UI.PixmapCache.getIcon("addUser"))
185        self.showDetailsButton.setIcon(UI.PixmapCache.getIcon("info"))
186
187        self.__pip = pip
188
189        self.packagesList.header().setSortIndicator(
190            0, Qt.SortOrder.AscendingOrder)
191
192        self.__infoLabels = {
193            "name": self.tr("Name:"),
194            "version": self.tr("Version:"),
195            "location": self.tr("Location:"),
196            "requires": self.tr("Requires:"),
197            "summary": self.tr("Summary:"),
198            "home-page": self.tr("Homepage:"),
199            "author": self.tr("Author:"),
200            "author-email": self.tr("Author Email:"),
201            "license": self.tr("License:"),
202            "metadata-version": self.tr("Metadata Version:"),
203            "installer": self.tr("Installer:"),
204            "classifiers": self.tr("Classifiers:"),
205            "entry-points": self.tr("Entry Points:"),
206            "files": self.tr("Files:"),
207        }
208        self.infoWidget.setHeaderLabels(["Key", "Value"])
209
210        venvManager = e5App().getObject("VirtualEnvManager")
211        venvManager.virtualEnvironmentAdded.connect(
212            self.on_refreshButton_clicked)
213        venvManager.virtualEnvironmentRemoved.connect(
214            self.on_refreshButton_clicked)
215
216        project = e5App().getObject("Project")
217        project.projectOpened.connect(
218            self.on_refreshButton_clicked)
219        project.projectClosed.connect(
220            self.on_refreshButton_clicked)
221
222        self.__initPipMenu()
223        self.__populateEnvironments()
224        self.__updateActionButtons()
225
226        self.statusLabel.hide()
227        self.searchWidget.hide()
228
229        self.__queryName = []
230        self.__querySummary = []
231
232        self.__replies = []
233
234        self.__packageDetailsDialog = None
235
236    def __populateEnvironments(self):
237        """
238        Private method to get a list of environments and populate the selector.
239        """
240        self.environmentsComboBox.addItem("")
241        projectVenv = self.__pip.getProjectEnvironmentString()
242        if projectVenv:
243            self.environmentsComboBox.addItem(projectVenv)
244        self.environmentsComboBox.addItems(
245            self.__pip.getVirtualenvNames(
246                noRemote=True,
247                noConda=Preferences.getPip("ExcludeCondaEnvironments")
248            )
249        )
250
251    def __isPipAvailable(self):
252        """
253        Private method to check, if the pip package is available for the
254        selected environment.
255
256        @return flag indicating availability
257        @rtype bool
258        """
259        available = False
260
261        venvName = self.environmentsComboBox.currentText()
262        if venvName:
263            available = (
264                len(self.packagesList.findItems(
265                    "pip",
266                    Qt.MatchFlag.MatchExactly |
267                    Qt.MatchFlag.MatchCaseSensitive)) == 1
268            )
269
270        return available
271
272    def __availablePipVersion(self):
273        """
274        Private method to get the pip version of the selected environment.
275
276        @return tuple containing the version number or tuple with all zeros
277            in case pip is not available
278        @rtype tuple of int
279        """
280        pipVersionTuple = (0, 0, 0)
281        venvName = self.environmentsComboBox.currentText()
282        if venvName:
283            pipList = self.packagesList.findItems(
284                "pip",
285                Qt.MatchFlag.MatchExactly | Qt.MatchFlag.MatchCaseSensitive
286            )
287            if len(pipList) > 0:
288                pipVersionTuple = Globals.versionToTuple(pipList[0].text(1))
289
290        return pipVersionTuple
291
292    def getPip(self):
293        """
294        Public method to get a reference to the pip interface object.
295
296        @return reference to the pip interface object
297        @rtype Pip
298        """
299        return self.__pip
300
301    #######################################################################
302    ## Slots handling widget signals below
303    #######################################################################
304
305    def __selectedUpdateableItems(self):
306        """
307        Private method to get a list of selected items that can be updated.
308
309        @return list of selected items that can be updated
310        @rtype list of QTreeWidgetItem
311        """
312        return [
313            itm for itm in self.packagesList.selectedItems()
314            if bool(itm.text(2))
315        ]
316
317    def __allUpdateableItems(self):
318        """
319        Private method to get a list of all items that can be updated.
320
321        @return list of all items that can be updated
322        @rtype list of QTreeWidgetItem
323        """
324        updateableItems = []
325        for index in range(self.packagesList.topLevelItemCount()):
326            itm = self.packagesList.topLevelItem(index)
327            if itm.text(2):
328                updateableItems.append(itm)
329
330        return updateableItems
331
332    def __updateActionButtons(self):
333        """
334        Private method to set the state of the action buttons.
335        """
336        if self.__isPipAvailable():
337            self.upgradeButton.setEnabled(
338                bool(self.__selectedUpdateableItems()))
339            self.uninstallButton.setEnabled(
340                bool(self.packagesList.selectedItems()))
341            self.upgradeAllButton.setEnabled(
342                bool(self.__allUpdateableItems()))
343            self.showPackageDetailsButton.setEnabled(
344                len(self.packagesList.selectedItems()) == 1)
345        else:
346            self.upgradeButton.setEnabled(False)
347            self.uninstallButton.setEnabled(False)
348            self.upgradeAllButton.setEnabled(False)
349            self.showPackageDetailsButton.setEnabled(False)
350
351    def __refreshPackagesList(self):
352        """
353        Private method to referesh the packages list.
354        """
355        self.packagesList.clear()
356        venvName = self.environmentsComboBox.currentText()
357        if venvName:
358            interpreter = self.__pip.getVirtualenvInterpreter(venvName)
359            if interpreter:
360                self.statusLabel.show()
361                self.statusLabel.setText(
362                    self.tr("Getting installed packages..."))
363
364                with E5OverrideCursor():
365                    # 1. populate with installed packages
366                    self.packagesList.setUpdatesEnabled(False)
367                    installedPackages = self.__pip.getInstalledPackages(
368                        venvName,
369                        localPackages=self.localCheckBox.isChecked(),
370                        notRequired=self.notRequiredCheckBox.isChecked(),
371                        usersite=self.userCheckBox.isChecked(),
372                    )
373                    for package, version in installedPackages:
374                        QTreeWidgetItem(self.packagesList, [package, version])
375                    self.packagesList.setUpdatesEnabled(True)
376                    self.statusLabel.setText(
377                        self.tr("Getting outdated packages..."))
378                    QApplication.processEvents()
379
380                    # 2. update with update information
381                    self.packagesList.setUpdatesEnabled(False)
382                    outdatedPackages = self.__pip.getOutdatedPackages(
383                        venvName,
384                        localPackages=self.localCheckBox.isChecked(),
385                        notRequired=self.notRequiredCheckBox.isChecked(),
386                        usersite=self.userCheckBox.isChecked(),
387                    )
388                    for package, _version, latest in outdatedPackages:
389                        items = self.packagesList.findItems(
390                            package,
391                            Qt.MatchFlag.MatchExactly |
392                            Qt.MatchFlag.MatchCaseSensitive
393                        )
394                        if items:
395                            itm = items[0]
396                            itm.setText(2, latest)
397
398                    self.packagesList.sortItems(0, Qt.SortOrder.AscendingOrder)
399                    for col in range(self.packagesList.columnCount()):
400                        self.packagesList.resizeColumnToContents(col)
401                    self.packagesList.setUpdatesEnabled(True)
402                self.statusLabel.hide()
403
404        self.__updateActionButtons()
405        self.__updateSearchActionButtons()
406        self.__updateSearchButton()
407
408    @pyqtSlot(int)
409    def on_environmentsComboBox_currentIndexChanged(self, index):
410        """
411        Private slot handling the selection of a Python environment.
412
413        @param index index of the selected Python environment
414        @type int
415        """
416        self.__refreshPackagesList()
417
418    @pyqtSlot(bool)
419    def on_localCheckBox_clicked(self, checked):
420        """
421        Private slot handling the switching of the local mode.
422
423        @param checked state of the local check box
424        @type bool
425        """
426        self.__refreshPackagesList()
427
428    @pyqtSlot(bool)
429    def on_notRequiredCheckBox_clicked(self, checked):
430        """
431        Private slot handling the switching of the 'not required' mode.
432
433        @param checked state of the 'not required' check box
434        @type bool
435        """
436        self.__refreshPackagesList()
437
438    @pyqtSlot(bool)
439    def on_userCheckBox_clicked(self, checked):
440        """
441        Private slot handling the switching of the 'user-site' mode.
442
443        @param checked state of the 'user-site' check box
444        @type bool
445        """
446        self.__refreshPackagesList()
447
448    @pyqtSlot()
449    def on_packagesList_itemSelectionChanged(self):
450        """
451        Private slot handling the selection of a package.
452        """
453        self.infoWidget.clear()
454
455        if len(self.packagesList.selectedItems()) == 1:
456            itm = self.packagesList.selectedItems()[0]
457
458            environment = self.environmentsComboBox.currentText()
459            interpreter = self.__pip.getVirtualenvInterpreter(environment)
460            if not interpreter:
461                return
462
463            args = ["-m", "pip", "show"]
464            if self.verboseCheckBox.isChecked():
465                args.append("--verbose")
466            if self.installedFilesCheckBox.isChecked():
467                args.append("--files")
468            args.append(itm.text(0))
469
470            with E5OverrideCursor():
471                success, output = self.__pip.runProcess(args, interpreter)
472
473                if success and output:
474                    mode = self.ShowProcessGeneralMode
475                    for line in output.splitlines():
476                        line = line.rstrip()
477                        if line != "---":
478                            if mode != self.ShowProcessGeneralMode:
479                                if line[0] == " ":
480                                    QTreeWidgetItem(
481                                        self.infoWidget,
482                                        [" ", line.strip()])
483                                else:
484                                    mode = self.ShowProcessGeneralMode
485                            if mode == self.ShowProcessGeneralMode:
486                                try:
487                                    label, info = line.split(": ", 1)
488                                except ValueError:
489                                    label = line[:-1]
490                                    info = ""
491                                label = label.lower()
492                                if label in self.__infoLabels:
493                                    QTreeWidgetItem(
494                                        self.infoWidget,
495                                        [self.__infoLabels[label], info])
496                                if label == "files":
497                                    mode = self.ShowProcessFilesListMode
498                                elif label == "classifiers":
499                                    mode = self.ShowProcessClassifiersMode
500                                elif label == "entry-points":
501                                    mode = self.ShowProcessEntryPointsMode
502                    self.infoWidget.scrollToTop()
503
504                header = self.infoWidget.header()
505                header.setStretchLastSection(False)
506                header.resizeSections(QHeaderView.ResizeMode.ResizeToContents)
507                if (
508                    header.sectionSize(0) + header.sectionSize(1) <
509                    header.width()
510                ):
511                    header.setStretchLastSection(True)
512
513        self.__updateActionButtons()
514
515    @pyqtSlot(QTreeWidgetItem, int)
516    def on_packagesList_itemActivated(self, item, column):
517        """
518        Private slot reacting on a package item activation.
519
520        @param item reference to the activated item
521        @type QTreeWidgetItem
522        @param column activated column
523        @type int
524        """
525        packageName = item.text(0)
526        upgradable = bool(item.text(2))
527        if column == 1:
528            # show details for installed version
529            packageVersion = item.text(1)
530        else:
531            # show details for available version or installed one
532            if item.text(2):
533                packageVersion = item.text(2)
534            else:
535                packageVersion = item.text(1)
536
537        self.__showPackageDetails(packageName, packageVersion,
538                                  upgradable=upgradable)
539
540    @pyqtSlot(bool)
541    def on_verboseCheckBox_clicked(self, checked):
542        """
543        Private slot to handle a change of the verbose package information
544        checkbox.
545
546        @param checked state of the checkbox
547        @type bool
548        """
549        self.on_packagesList_itemSelectionChanged()
550
551    @pyqtSlot(bool)
552    def on_installedFilesCheckBox_clicked(self, checked):
553        """
554        Private slot to handle a change of the installed files information
555        checkbox.
556
557        @param checked state of the checkbox
558        @type bool
559        """
560        self.on_packagesList_itemSelectionChanged()
561
562    @pyqtSlot()
563    def on_refreshButton_clicked(self):
564        """
565        Private slot to refresh the display.
566        """
567        currentEnvironment = self.environmentsComboBox.currentText()
568        self.environmentsComboBox.clear()
569        self.packagesList.clear()
570
571        with E5OverrideCursor():
572            self.__populateEnvironments()
573
574            index = self.environmentsComboBox.findText(
575                currentEnvironment,
576                Qt.MatchFlag.MatchExactly | Qt.MatchFlag.MatchCaseSensitive
577            )
578            if index != -1:
579                self.environmentsComboBox.setCurrentIndex(index)
580
581        self.__updateActionButtons()
582
583    @pyqtSlot()
584    def on_upgradeButton_clicked(self):
585        """
586        Private slot to upgrade selected packages of the selected environment.
587        """
588        packages = [itm.text(0) for itm in self.__selectedUpdateableItems()]
589        if packages:
590            self.executeUpgradePackages(packages)
591
592    @pyqtSlot()
593    def on_upgradeAllButton_clicked(self):
594        """
595        Private slot to upgrade all packages of the selected environment.
596        """
597        packages = [itm.text(0) for itm in self.__allUpdateableItems()]
598        if packages:
599            self.executeUpgradePackages(packages)
600
601    @pyqtSlot()
602    def on_uninstallButton_clicked(self):
603        """
604        Private slot to remove selected packages of the selected environment.
605        """
606        packages = [itm.text(0) for itm in self.packagesList.selectedItems()]
607        self.executeUninstallPackages(packages)
608
609    def executeUninstallPackages(self, packages):
610        """
611        Public method to uninstall the given list of packages.
612
613        @param packages list of package names to be uninstalled
614        @type list of str
615        """
616        if packages:
617            ok = self.__pip.uninstallPackages(
618                packages,
619                venvName=self.environmentsComboBox.currentText())
620            if ok:
621                self.on_refreshButton_clicked()
622
623    def executeUpgradePackages(self, packages):
624        """
625        Public method to execute the pip upgrade command.
626
627        @param packages list of package names to be upgraded
628        @type list of str
629        """
630        ok = self.__pip.upgradePackages(
631            packages, venvName=self.environmentsComboBox.currentText(),
632            userSite=self.userCheckBox.isChecked())
633        if ok:
634            self.on_refreshButton_clicked()
635
636    @pyqtSlot()
637    def on_showPackageDetailsButton_clicked(self):
638        """
639        Private slot to show information for the selected package.
640        """
641        item = self.packagesList.selectedItems()[0]
642        if item:
643            packageName = item.text(0)
644            upgradable = bool(item.text(2))
645            # show details for available version or installed one
646            if item.text(2):
647                packageVersion = item.text(2)
648            else:
649                packageVersion = item.text(1)
650
651            self.__showPackageDetails(packageName, packageVersion,
652                                      upgradable=upgradable)
653
654    #######################################################################
655    ## Search widget related methods below
656    #######################################################################
657
658    def __updateSearchActionButtons(self):
659        """
660        Private method to update the action button states of the search widget.
661        """
662        installEnable = (
663            len(self.searchResultList.selectedItems()) > 0 and
664            self.environmentsComboBox.currentIndex() > 0 and
665            self.__isPipAvailable()
666        )
667        self.installButton.setEnabled(installEnable)
668        self.installUserSiteButton.setEnabled(installEnable)
669
670        self.showDetailsButton.setEnabled(
671            len(self.searchResultList.selectedItems()) == 1 and
672            self.__isPipAvailable()
673        )
674
675    def __updateSearchButton(self):
676        """
677        Private method to update the state of the search button.
678        """
679        self.searchButton.setEnabled(
680            bool(self.searchEditName.text()) and
681            self.__isPipAvailable()
682        )
683
684    @pyqtSlot(bool)
685    def on_searchToggleButton_toggled(self, checked):
686        """
687        Private slot to togle the search widget.
688
689        @param checked state of the search widget button
690        @type bool
691        """
692        self.searchWidget.setVisible(checked)
693
694        if checked:
695            self.searchEditName.setFocus(Qt.FocusReason.OtherFocusReason)
696            self.searchEditName.selectAll()
697
698            self.__updateSearchActionButtons()
699            self.__updateSearchButton()
700
701    @pyqtSlot(str)
702    def on_searchEditName_textChanged(self, txt):
703        """
704        Private slot handling a change of the search term.
705
706        @param txt search term
707        @type str
708        """
709        self.__updateSearchButton()
710
711    @pyqtSlot()
712    def on_searchEditName_returnPressed(self):
713        """
714        Private slot initiating a search via a press of the Return key.
715        """
716        if (
717            bool(self.searchEditName.text()) and
718            self.__isPipAvailable()
719        ):
720            self.__search()
721
722    @pyqtSlot()
723    def on_searchButton_clicked(self):
724        """
725        Private slot handling a press of the search button.
726        """
727        self.__search()
728
729    @pyqtSlot()
730    def on_searchResultList_itemSelectionChanged(self):
731        """
732        Private slot handling changes of the search result selection.
733        """
734        self.__updateSearchActionButtons()
735
736    def __search(self):
737        """
738        Private method to perform the search by calling the PyPI search URL.
739        """
740        self.searchResultList.clear()
741        self.searchInfoLabel.clear()
742
743        self.searchButton.setEnabled(False)
744
745        searchTerm = self.searchEditName.text().strip()
746        searchTerm = bytes(QUrl.toPercentEncoding(searchTerm)).decode()
747        urlQuery = QUrlQuery()
748        urlQuery.addQueryItem("q", searchTerm)
749        url = QUrl(self.__pip.getIndexUrlSearch())
750        url.setQuery(urlQuery)
751
752        request = QNetworkRequest(QUrl(url))
753        request.setAttribute(
754            QNetworkRequest.Attribute.CacheLoadControlAttribute,
755            QNetworkRequest.CacheLoadControl.AlwaysNetwork)
756        reply = self.__pip.getNetworkAccessManager().get(request)
757        reply.finished.connect(
758            lambda: self.__searchResponse(reply))
759        self.__replies.append(reply)
760
761    def __searchResponse(self, reply):
762        """
763        Private method to extract the search result data from the response.
764
765        @param reply reference to the reply object containing the data
766        @type QNetworkReply
767        """
768        if reply in self.__replies:
769            self.__replies.remove(reply)
770
771        urlQuery = QUrlQuery(reply.url())
772        searchTerm = urlQuery.queryItemValue("q")
773
774        if reply.error() != QNetworkReply.NetworkError.NoError:
775            E5MessageBox.warning(
776                None,
777                self.tr("Search PyPI"),
778                self.tr(
779                    "<p>Received an error while searching for <b>{0}</b>.</p>"
780                    "<p>Error: {1}</p>"
781                ).format(searchTerm, reply.errorString())
782            )
783            reply.deleteLater()
784            return
785
786        data = bytes(reply.readAll()).decode()
787        reply.deleteLater()
788
789        results = PypiSearchResultsParser(data).getResults()
790        if results:
791            if len(results) < 20:
792                msg = self.tr("%n package(s) found.", "", len(results))
793            else:
794                msg = self.tr("Showing first 20 packages found.")
795            self.searchInfoLabel.setText(msg)
796        else:
797            E5MessageBox.warning(
798                self,
799                self.tr("Search PyPI"),
800                self.tr("""<p>There were no results for <b>{0}</b>.</p>"""))
801            self.searchInfoLabel.setText(
802                self.tr("""<p>There were no results for <b>{0}</b>.</p>"""))
803
804        wrapper = textwrap.TextWrapper(width=80)
805        for result in results:
806            try:
807                description = "\n".join([
808                    wrapper.fill(line) for line in
809                    result['description'].strip().splitlines()
810                ])
811            except KeyError:
812                description = ""
813            itm = QTreeWidgetItem(
814                self.searchResultList, [
815                    result['name'].strip(),
816                    result['version'],
817                    result["released"].strip(),
818                    description,
819                ])
820            itm.setData(0, self.SearchVersionRole, result['version'])
821
822        header = self.searchResultList.header()
823        header.setStretchLastSection(False)
824        header.resizeSections(QHeaderView.ResizeMode.ResizeToContents)
825        headerSize = 0
826        for col in range(header.count()):
827            headerSize += header.sectionSize(col)
828        if headerSize < header.width():
829            header.setStretchLastSection(True)
830
831        self.__finishSearch()
832
833    def __finishSearch(self):
834        """
835        Private slot performing the search finishing actions.
836        """
837        self.__updateSearchActionButtons()
838        self.__updateSearchButton()
839
840        self.searchEditName.setFocus(Qt.FocusReason.OtherFocusReason)
841
842    @pyqtSlot()
843    def on_installButton_clicked(self):
844        """
845        Private slot to handle pressing the Install button..
846        """
847        packages = [
848            itm.text(0).strip()
849            for itm in self.searchResultList.selectedItems()
850        ]
851        self.executeInstallPackages(packages)
852
853    @pyqtSlot()
854    def on_installUserSiteButton_clicked(self):
855        """
856        Private slot to handle pressing the Install to User-Site button..
857        """
858        packages = [
859            itm.text(0).strip()
860            for itm in self.searchResultList.selectedItems()
861        ]
862        self.executeInstallPackages(packages, userSite=True)
863
864    def executeInstallPackages(self, packages, userSite=False):
865        """
866        Public method to install the given list of packages.
867
868        @param packages list of package names to be installed
869        @type list of str
870        @param userSite flag indicating to install to the user directory
871        @type bool
872        """
873        venvName = self.environmentsComboBox.currentText()
874        if venvName and packages:
875            self.__pip.installPackages(packages, venvName=venvName,
876                                       userSite=userSite)
877            self.on_refreshButton_clicked()
878
879    @pyqtSlot()
880    def on_showDetailsButton_clicked(self):
881        """
882        Private slot to handle pressing the Show Details button.
883        """
884        self.__showSearchedDetails()
885
886    @pyqtSlot(QTreeWidgetItem, int)
887    def on_searchResultList_itemActivated(self, item, column):
888        """
889        Private slot reacting on an search result item activation.
890
891        @param item reference to the activated item
892        @type QTreeWidgetItem
893        @param column activated column
894        @type int
895        """
896        self.__showSearchedDetails(item)
897
898    def __showSearchedDetails(self, item=None):
899        """
900        Private slot to show details about the selected search result package.
901
902        @param item reference to the search result item to show details for
903        @type QTreeWidgetItem
904        """
905        self.showDetailsButton.setEnabled(False)
906
907        if not item:
908            item = self.searchResultList.selectedItems()[0]
909
910        packageVersion = item.data(0, self.SearchVersionRole)
911        packageName = item.text(0)
912
913        self.__showPackageDetails(packageName, packageVersion,
914                                  installable=True)
915
916    def __showPackageDetails(self, packageName, packageVersion,
917                             upgradable=False, installable=False):
918        """
919        Private method to populate the package details dialog.
920
921        @param packageName name of the package to show details for
922        @type str
923        @param packageVersion version of the package
924        @type str
925        @param upgradable flag indicating that the package may be upgraded
926            (defaults to False)
927        @type bool (optional)
928        @param installable flag indicating that the package may be installed
929            (defaults to False)
930        @type bool (optional)
931        """
932        with E5OverrideCursor():
933            packageData = self.__pip.getPackageDetails(
934                packageName, packageVersion)
935
936        if packageData:
937            from .PipPackageDetailsDialog import PipPackageDetailsDialog
938
939            self.showDetailsButton.setEnabled(True)
940
941            if installable:
942                buttonsMode = PipPackageDetailsDialog.ButtonInstall
943            elif upgradable:
944                buttonsMode = (
945                    PipPackageDetailsDialog.ButtonRemove |
946                    PipPackageDetailsDialog.ButtonUpgrade
947                )
948            else:
949                buttonsMode = PipPackageDetailsDialog.ButtonRemove
950
951            if self.__packageDetailsDialog is not None:
952                self.__packageDetailsDialog.close()
953
954            self.__packageDetailsDialog = (
955                PipPackageDetailsDialog(packageData, buttonsMode=buttonsMode,
956                                        parent=self)
957            )
958            self.__packageDetailsDialog.show()
959        else:
960            E5MessageBox.warning(
961                self,
962                self.tr("Search PyPI"),
963                self.tr("""<p>No package details info for <b>{0}</b>"""
964                        """ available.</p>""").format(packageName))
965
966    #######################################################################
967    ## Menu related methods below
968    #######################################################################
969
970    def __initPipMenu(self):
971        """
972        Private method to create the super menu and attach it to the super
973        menu button.
974        """
975        self.__pipMenu = QMenu()
976        self.__installPipAct = self.__pipMenu.addAction(
977            self.tr("Install Pip"),
978            self.__installPip)
979        self.__installPipUserAct = self.__pipMenu.addAction(
980            self.tr("Install Pip to User-Site"),
981            self.__installPipUser)
982        self.__repairPipAct = self.__pipMenu.addAction(
983            self.tr("Repair Pip"),
984            self.__repairPip)
985        self.__pipMenu.addSeparator()
986        self.__installPackagesAct = self.__pipMenu.addAction(
987            self.tr("Install Packages"),
988            self.__installPackages)
989        self.__installLocalPackageAct = self.__pipMenu.addAction(
990            self.tr("Install Local Package"),
991            self.__installLocalPackage)
992        self.__pipMenu.addSeparator()
993        self.__installRequirementsAct = self.__pipMenu.addAction(
994            self.tr("Install Requirements"),
995            self.__installRequirements)
996        self.__reinstallPackagesAct = self.__pipMenu.addAction(
997            self.tr("Re-Install Selected Packages"),
998            self.__reinstallPackages)
999        self.__uninstallRequirementsAct = self.__pipMenu.addAction(
1000            self.tr("Uninstall Requirements"),
1001            self.__uninstallRequirements)
1002        self.__generateRequirementsAct = self.__pipMenu.addAction(
1003            self.tr("Generate Requirements..."),
1004            self.__generateRequirements)
1005        self.__pipMenu.addSeparator()
1006        self.__cacheInfoAct = self.__pipMenu.addAction(
1007            self.tr("Show Cache Info..."),
1008            self.__showCacheInfo)
1009        self.__cacheShowListAct = self.__pipMenu.addAction(
1010            self.tr("Show Cached Files..."),
1011            self.__showCacheList)
1012        self.__cacheRemoveAct = self.__pipMenu.addAction(
1013            self.tr("Remove Cached Files..."),
1014            self.__removeCachedFiles)
1015        self.__cachePurgeAct = self.__pipMenu.addAction(
1016            self.tr("Purge Cache..."),
1017            self.__purgeCache)
1018        self.__pipMenu.addSeparator()
1019        # editUserConfigAct
1020        self.__pipMenu.addAction(
1021            self.tr("Edit User Configuration..."),
1022            self.__editUserConfiguration)
1023        self.__editVirtualenvConfigAct = self.__pipMenu.addAction(
1024            self.tr("Edit Environment Configuration..."),
1025            self.__editVirtualenvConfiguration)
1026        self.__pipMenu.addSeparator()
1027        # pipConfigAct
1028        self.__pipMenu.addAction(
1029            self.tr("Configure..."),
1030            self.__pipConfigure)
1031
1032        self.__pipMenu.aboutToShow.connect(self.__aboutToShowPipMenu)
1033
1034        self.pipMenuButton.setMenu(self.__pipMenu)
1035
1036    def __aboutToShowPipMenu(self):
1037        """
1038        Private slot to set the action enabled status.
1039        """
1040        enable = bool(self.environmentsComboBox.currentText())
1041        enablePip = self.__isPipAvailable()
1042        enablePipCache = self.__availablePipVersion() >= (20, 1, 0)
1043
1044        self.__installPipAct.setEnabled(not enablePip)
1045        self.__installPipUserAct.setEnabled(not enablePip)
1046        self.__repairPipAct.setEnabled(enablePip)
1047
1048        self.__installPackagesAct.setEnabled(enablePip)
1049        self.__installLocalPackageAct.setEnabled(enablePip)
1050        self.__reinstallPackagesAct.setEnabled(enablePip)
1051
1052        self.__installRequirementsAct.setEnabled(enablePip)
1053        self.__uninstallRequirementsAct.setEnabled(enablePip)
1054        self.__generateRequirementsAct.setEnabled(enablePip)
1055
1056        self.__cacheInfoAct.setEnabled(enablePipCache)
1057        self.__cacheShowListAct.setEnabled(enablePipCache)
1058        self.__cacheRemoveAct.setEnabled(enablePipCache)
1059        self.__cachePurgeAct.setEnabled(enablePipCache)
1060
1061        self.__editVirtualenvConfigAct.setEnabled(enable)
1062
1063    @pyqtSlot()
1064    def __installPip(self):
1065        """
1066        Private slot to install pip into the selected environment.
1067        """
1068        venvName = self.environmentsComboBox.currentText()
1069        if venvName:
1070            self.__pip.installPip(venvName)
1071            self.on_refreshButton_clicked()
1072
1073    @pyqtSlot()
1074    def __installPipUser(self):
1075        """
1076        Private slot to install pip into the user site for the selected
1077        environment.
1078        """
1079        venvName = self.environmentsComboBox.currentText()
1080        if venvName:
1081            self.__pip.installPip(venvName, userSite=True)
1082            self.on_refreshButton_clicked()
1083
1084    @pyqtSlot()
1085    def __repairPip(self):
1086        """
1087        Private slot to repair the pip installation of the selected
1088        environment.
1089        """
1090        venvName = self.environmentsComboBox.currentText()
1091        if venvName:
1092            self.__pip.repairPip(venvName)
1093            self.on_refreshButton_clicked()
1094
1095    @pyqtSlot()
1096    def __installPackages(self):
1097        """
1098        Private slot to install packages to be given by the user.
1099        """
1100        venvName = self.environmentsComboBox.currentText()
1101        if venvName:
1102            from .PipPackagesInputDialog import PipPackagesInputDialog
1103            dlg = PipPackagesInputDialog(self, self.tr("Install Packages"))
1104            if dlg.exec() == QDialog.DialogCode.Accepted:
1105                packages, user = dlg.getData()
1106                self.executeInstallPackages(packages, userSite=user)
1107
1108    @pyqtSlot()
1109    def __installLocalPackage(self):
1110        """
1111        Private slot to install a package available on local storage.
1112        """
1113        venvName = self.environmentsComboBox.currentText()
1114        if venvName:
1115            from .PipFileSelectionDialog import PipFileSelectionDialog
1116            dlg = PipFileSelectionDialog(self, "package")
1117            if dlg.exec() == QDialog.DialogCode.Accepted:
1118                package, user = dlg.getData()
1119                if package and os.path.exists(package):
1120                    self.executeInstallPackages([package], userSite=user)
1121
1122    @pyqtSlot()
1123    def __reinstallPackages(self):
1124        """
1125        Private slot to force a re-installation of the selected packages.
1126        """
1127        packages = [itm.text(0) for itm in self.packagesList.selectedItems()]
1128        venvName = self.environmentsComboBox.currentText()
1129        if venvName and packages:
1130            self.__pip.installPackages(packages, venvName=venvName,
1131                                       forceReinstall=True)
1132            self.on_refreshButton_clicked()
1133
1134    @pyqtSlot()
1135    def __installRequirements(self):
1136        """
1137        Private slot to install packages as given in a requirements file.
1138        """
1139        venvName = self.environmentsComboBox.currentText()
1140        if venvName:
1141            self.__pip.installRequirements(venvName)
1142            self.on_refreshButton_clicked()
1143
1144    @pyqtSlot()
1145    def __uninstallRequirements(self):
1146        """
1147        Private slot to uninstall packages as given in a requirements file.
1148        """
1149        venvName = self.environmentsComboBox.currentText()
1150        if venvName:
1151            self.__pip.uninstallRequirements(venvName)
1152            self.on_refreshButton_clicked()
1153
1154    @pyqtSlot()
1155    def __generateRequirements(self):
1156        """
1157        Private slot to generate the contents for a requirements file.
1158        """
1159        venvName = self.environmentsComboBox.currentText()
1160        if venvName:
1161            from .PipFreezeDialog import PipFreezeDialog
1162            self.__freezeDialog = PipFreezeDialog(self.__pip, self)
1163            self.__freezeDialog.show()
1164            self.__freezeDialog.start(venvName)
1165
1166    @pyqtSlot()
1167    def __editUserConfiguration(self):
1168        """
1169        Private slot to edit the user configuration.
1170        """
1171        self.__editConfiguration()
1172
1173    @pyqtSlot()
1174    def __editVirtualenvConfiguration(self):
1175        """
1176        Private slot to edit the configuration of the selected environment.
1177        """
1178        venvName = self.environmentsComboBox.currentText()
1179        if venvName:
1180            self.__editConfiguration(venvName=venvName)
1181
1182    def __editConfiguration(self, venvName=""):
1183        """
1184        Private method to edit a configuration.
1185
1186        @param venvName name of the environment to act upon
1187        @type str
1188        """
1189        from QScintilla.MiniEditor import MiniEditor
1190        if venvName:
1191            cfgFile = self.__pip.getVirtualenvConfig(venvName)
1192            if not cfgFile:
1193                return
1194        else:
1195            cfgFile = self.__pip.getUserConfig()
1196        cfgDir = os.path.dirname(cfgFile)
1197        if not cfgDir:
1198            E5MessageBox.critical(
1199                None,
1200                self.tr("Edit Configuration"),
1201                self.tr("""No valid configuration path determined."""
1202                        """ Aborting"""))
1203            return
1204
1205        try:
1206            if not os.path.isdir(cfgDir):
1207                os.makedirs(cfgDir)
1208        except OSError:
1209            E5MessageBox.critical(
1210                None,
1211                self.tr("Edit Configuration"),
1212                self.tr("""No valid configuration path determined."""
1213                        """ Aborting"""))
1214            return
1215
1216        if not os.path.exists(cfgFile):
1217            with contextlib.suppress(OSError), open(cfgFile, "w") as f:
1218                f.write("[global]\n")
1219
1220        # check, if the destination is writeable
1221        if not os.access(cfgFile, os.W_OK):
1222            E5MessageBox.critical(
1223                None,
1224                self.tr("Edit Configuration"),
1225                self.tr("""No valid configuration path determined."""
1226                        """ Aborting"""))
1227            return
1228
1229        self.__editor = MiniEditor(cfgFile, "Properties")
1230        self.__editor.show()
1231
1232    def __pipConfigure(self):
1233        """
1234        Private slot to open the configuration page.
1235        """
1236        e5App().getObject("UserInterface").showPreferences("pipPage")
1237
1238    @pyqtSlot()
1239    def __showCacheInfo(self):
1240        """
1241        Private slot to show information about the cache.
1242        """
1243        venvName = self.environmentsComboBox.currentText()
1244        if venvName:
1245            self.__pip.showCacheInfo(venvName)
1246
1247    @pyqtSlot()
1248    def __showCacheList(self):
1249        """
1250        Private slot to show a list of cached files.
1251        """
1252        venvName = self.environmentsComboBox.currentText()
1253        if venvName:
1254            self.__pip.cacheList(venvName)
1255
1256    @pyqtSlot()
1257    def __removeCachedFiles(self):
1258        """
1259        Private slot to remove files from the pip cache.
1260        """
1261        venvName = self.environmentsComboBox.currentText()
1262        if venvName:
1263            self.__pip.cacheRemove(venvName)
1264
1265    @pyqtSlot()
1266    def __purgeCache(self):
1267        """
1268        Private slot to empty the pip cache.
1269        """
1270        venvName = self.environmentsComboBox.currentText()
1271        if venvName:
1272            self.__pip.cachePurge(venvName)
1273