eric6/PipInterface/PipPackagesWidget.py

changeset 8085
f6db8b3ecea9
parent 8056
6e89221ff9dd
child 8090
c53117374255
equal deleted inserted replaced
8084:7742e0b96629 8085:f6db8b3ecea9
7 Module implementing the pip packages management widget. 7 Module implementing the pip packages management widget.
8 """ 8 """
9 9
10 import textwrap 10 import textwrap
11 import os 11 import os
12 import re 12 import html.parser
13 13
14 from PyQt5.QtCore import pyqtSlot, Qt 14 from PyQt5.QtCore import pyqtSlot, Qt, QUrl, QUrlQuery
15 from PyQt5.QtNetwork import QNetworkReply, QNetworkRequest
15 from PyQt5.QtWidgets import ( 16 from PyQt5.QtWidgets import (
16 QWidget, QToolButton, QApplication, QHeaderView, QTreeWidgetItem, 17 QWidget, QToolButton, QApplication, QHeaderView, QTreeWidgetItem,
17 QInputDialog, QMenu, QDialog 18 QMenu, QDialog
18 ) 19 )
19 20
20 from E5Gui.E5Application import e5App 21 from E5Gui.E5Application import e5App
21 from E5Gui import E5MessageBox 22 from E5Gui import E5MessageBox
22 from E5Gui.E5OverrideCursor import E5OverrideCursor 23 from E5Gui.E5OverrideCursor import E5OverrideCursor
23 24
24 from E5Network.E5XmlRpcClient import E5XmlRpcClient
25
26 from .Ui_PipPackagesWidget import Ui_PipPackagesWidget 25 from .Ui_PipPackagesWidget import Ui_PipPackagesWidget
27 26
28 import UI.PixmapCache 27 import UI.PixmapCache
29 import Globals 28 import Globals
30 import Preferences 29 import Preferences
30
31
32 class PypiSearchResultsParser(html.parser.HTMLParser):
33 """
34 Class implementing the parser for the PyPI search result page.
35 """
36 ClassPrefix = "package-snippet__"
37
38 def __init__(self, data):
39 """
40 Constructor
41
42 @param data data to be parsed
43 @type str
44 """
45 super(PypiSearchResultsParser, self).__init__()
46 self.__results = []
47 self.__activeClass = None
48 self.feed(data)
49
50 def __getClass(self, attrs):
51 """
52 Private method to extract the class attribute out of the list of
53 attributes.
54
55 @param attrs list of tag attributes as (name, value) tuples
56 @type list of tuple of (str, str)
57 @return value of the 'class' attribute or None
58 @rtype str
59 """
60 for name, value in attrs:
61 if name == "class":
62 return value
63
64 return None
65
66 def __getDate(self, attrs):
67 """
68 Private method to extract the datetime attribute out of the list of
69 attributes and process it.
70
71 @param attrs list of tag attributes as (name, value) tuples
72 @type list of tuple of (str, str)
73 @return value of the 'class' attribute or None
74 @rtype str
75 """
76 for name, value in attrs:
77 if name == "datetime":
78 return value.split("T")[0]
79
80 return None
81
82 def handle_starttag(self, tag, attrs):
83 """
84 Public method to process the start tag.
85
86 @param tag tag name (all lowercase)
87 @type str
88 @param attrs list of tag attributes as (name, value) tuples
89 @type list of tuple of (str, str)
90 """
91 if tag == "a" and self.__getClass(attrs) == "package-snippet":
92 self.__results.append({})
93
94 if tag in ("span", "p"):
95 tagClass = self.__getClass(attrs)
96 if tagClass in (
97 "package-snippet__name", "package-snippet__description",
98 "package-snippet__version", "package-snippet__released",
99 ):
100 self.__activeClass = tagClass
101 else:
102 self.__activeClass = None
103 elif tag == "time":
104 attributeName = self.__activeClass.replace(self.ClassPrefix, "")
105 self.__results[-1][attributeName] = self.__getDate(attrs)
106 self.__activeClass = None
107 else:
108 self.__activeClass = None
109
110 def handle_data(self, data):
111 """
112 Public method process arbitrary data.
113
114 @param data data to be processed
115 @type str
116 """
117 if self.__activeClass is not None:
118 attributeName = self.__activeClass.replace(self.ClassPrefix, "")
119 self.__results[-1][attributeName] = data
120
121 def handle_endtag(self, tag):
122 """
123 Public method to process the end tag.
124
125 @param tag tag name (all lowercase)
126 @type str
127 """
128 self.__activeClass = None
129
130 def getResults(self):
131 """
132 Public method to get the extracted search results.
133
134 @return extracted result data
135 @rtype list of dict
136 """
137 return self.__results
31 138
32 139
33 class PipPackagesWidget(QWidget, Ui_PipPackagesWidget): 140 class PipPackagesWidget(QWidget, Ui_PipPackagesWidget):
34 """ 141 """
35 Class implementing the pip packages management widget. 142 Class implementing the pip packages management widget.
37 ShowProcessGeneralMode = 0 144 ShowProcessGeneralMode = 0
38 ShowProcessClassifiersMode = 1 145 ShowProcessClassifiersMode = 1
39 ShowProcessEntryPointsMode = 2 146 ShowProcessEntryPointsMode = 2
40 ShowProcessFilesListMode = 3 147 ShowProcessFilesListMode = 3
41 148
42 SearchStopwords = {
43 "a", "and", "are", "as", "at", "be", "but", "by",
44 "for", "if", "in", "into", "is", "it",
45 "no", "not", "of", "on", "or", "such",
46 "that", "the", "their", "then", "there", "these",
47 "they", "this", "to", "was", "will",
48 }
49 SearchVersionRole = Qt.UserRole + 1 149 SearchVersionRole = Qt.UserRole + 1
150
151 SearchUrl = "https://pypi.org/search/"
50 152
51 def __init__(self, pip, parent=None): 153 def __init__(self, pip, parent=None):
52 """ 154 """
53 Constructor 155 Constructor
54 156
80 self.installButton.setIcon(UI.PixmapCache.getIcon("plus")) 182 self.installButton.setIcon(UI.PixmapCache.getIcon("plus"))
81 self.installUserSiteButton.setIcon(UI.PixmapCache.getIcon("addUser")) 183 self.installUserSiteButton.setIcon(UI.PixmapCache.getIcon("addUser"))
82 self.showDetailsButton.setIcon(UI.PixmapCache.getIcon("info")) 184 self.showDetailsButton.setIcon(UI.PixmapCache.getIcon("info"))
83 185
84 self.__pip = pip 186 self.__pip = pip
85 self.__client = E5XmlRpcClient(self.__pip.getIndexUrlXml(), self)
86 187
87 self.packagesList.header().setSortIndicator(0, Qt.AscendingOrder) 188 self.packagesList.header().setSortIndicator(0, Qt.AscendingOrder)
88 189
89 self.__infoLabels = { 190 self.__infoLabels = {
90 "name": self.tr("Name:"), 191 "name": self.tr("Name:"),
123 self.statusLabel.hide() 224 self.statusLabel.hide()
124 self.searchWidget.hide() 225 self.searchWidget.hide()
125 226
126 self.__queryName = [] 227 self.__queryName = []
127 self.__querySummary = [] 228 self.__querySummary = []
229
230 self.__replies = []
128 231
129 self.__packageDetailsDialog = None 232 self.__packageDetailsDialog = None
130 233
131 def __populateEnvironments(self): 234 def __populateEnvironments(self):
132 """ 235 """
542 def __updateSearchButton(self): 645 def __updateSearchButton(self):
543 """ 646 """
544 Private method to update the state of the search button. 647 Private method to update the state of the search button.
545 """ 648 """
546 self.searchButton.setEnabled( 649 self.searchButton.setEnabled(
547 (bool(self.searchEditName.text()) or 650 bool(self.searchEditName.text()) and
548 bool(self.searchEditSummary.text())) and
549 self.__isPipAvailable() 651 self.__isPipAvailable()
550 ) 652 )
551 653
552 @pyqtSlot(bool) 654 @pyqtSlot(bool)
553 def on_searchToggleButton_toggled(self, checked): 655 def on_searchToggleButton_toggled(self, checked):
580 def on_searchEditName_returnPressed(self): 682 def on_searchEditName_returnPressed(self):
581 """ 683 """
582 Private slot initiating a search via a press of the Return key. 684 Private slot initiating a search via a press of the Return key.
583 """ 685 """
584 if ( 686 if (
585 (bool(self.searchEditName.text()) or 687 bool(self.searchEditName.text()) and
586 bool(self.searchEditSummary.text())) and
587 self.__isPipAvailable() 688 self.__isPipAvailable()
588 ): 689 ):
589 self.__search() 690 self.__search()
590 691
591 @pyqtSlot(str)
592 def on_searchEditSummary_textChanged(self, txt):
593 """
594 Private slot handling a change of the search term.
595
596 @param txt search term
597 @type str
598 """
599 self.__updateSearchButton()
600
601 @pyqtSlot()
602 def on_searchEditSummary_returnPressed(self):
603 """
604 Private slot initiating a search via a press of the Return key.
605 """
606 if (
607 (bool(self.searchEditName.text()) or
608 bool(self.searchEditSummary.text())) and
609 self.__isPipAvailable()
610 ):
611 self.__search()
612
613 @pyqtSlot() 692 @pyqtSlot()
614 def on_searchButton_clicked(self): 693 def on_searchButton_clicked(self):
615 """ 694 """
616 Private slot handling a press of the search button. 695 Private slot handling a press of the search button.
617 """ 696 """
624 """ 703 """
625 self.__updateSearchActionButtons() 704 self.__updateSearchActionButtons()
626 705
627 def __search(self): 706 def __search(self):
628 """ 707 """
629 Private method to perform the search. 708 Private method to perform the search by calling the PyPI search URL.
630 """ 709 """
631 # TODO: change search to use web scraping to get rid of XML-RPC
632 # see thonny for how to do it
633 self.searchResultList.clear() 710 self.searchResultList.clear()
634 self.searchInfoLabel.clear() 711 self.searchInfoLabel.clear()
635 712
636 self.searchButton.setEnabled(False) 713 self.searchButton.setEnabled(False)
637 714
638 self.__queryName = [ 715 searchTerm = self.searchEditName.text().strip()
639 term for term in self.searchEditName.text().strip().split() 716 searchTerm = bytes(QUrl.toPercentEncoding(searchTerm)).decode()
640 if term not in self.SearchStopwords 717 urlQuery = QUrlQuery()
641 ] 718 urlQuery.addQueryItem("q", searchTerm)
642 self.__querySummary = [ 719 url = QUrl(self.SearchUrl)
643 term for term in self.searchEditSummary.text().strip().split() 720 url.setQuery(urlQuery)
644 if term not in self.SearchStopwords 721
645 ] 722 request = QNetworkRequest(QUrl(url))
646 self.__client.call( 723 request.setAttribute(QNetworkRequest.CacheLoadControlAttribute,
647 "search", 724 QNetworkRequest.AlwaysNetwork)
648 ({"name": self.__queryName, 725 reply = self.__pip.getNetworkAccessManager().get(request)
649 "summary": self.__querySummary}, 726 reply.finished.connect(
650 self.searchTermCombineComboBox.currentText()), 727 lambda: self.__searchResponse(reply))
651 self.__processSearchResult, 728 self.__replies.append(reply)
652 self.__searchError 729
653 ) 730 def __searchResponse(self, reply):
654 731 """
655 def __processSearchResult(self, data): 732 Private method to extract the search result data from the response.
656 """ 733
657 Private method to process the search result data from PyPI. 734 @param reply reference to the reply object containing the data
658 735 @type QNetworkReply
659 @param data result data with hits in the first element 736 """
660 @type tuple 737 if reply in self.__replies:
661 """ 738 self.__replies.remove(reply)
662 if data: 739
663 packages = self.__transformHits(data[0]) 740 urlQuery = QUrlQuery(reply.url())
664 if packages: 741 searchTerm = urlQuery.queryItemValue("q")
665 self.searchInfoLabel.setText( 742
666 self.tr("%n package(s) found.", "", len(packages))) 743 if reply.error() != QNetworkReply.NoError:
667 wrapper = textwrap.TextWrapper(width=80) 744 E5MessageBox.warning(
668 count = 0 745 None,
669 total = 0 746 self.tr("Search PyPI"),
670 for package in packages: 747 self.tr(
671 itm = QTreeWidgetItem( 748 "<p>Received an error while searching for <b>{0}</b>.</p>"
672 self.searchResultList, [ 749 "<p>Error: {1}</p>"
673 package['name'].strip(), 750 ).format(searchTerm, reply.errorString())
674 "{0:4d}".format(package['score']), 751 )
675 "\n".join([ 752 reply.deleteLater()
676 wrapper.fill(line) for line in 753 return
677 package['summary'].strip().splitlines() 754
678 ]) 755 data = bytes(reply.readAll()).decode()
679 ]) 756 reply.deleteLater()
680 itm.setData(0, self.SearchVersionRole, package['version']) 757
681 count += 1 758 results = PypiSearchResultsParser(data).getResults()
682 total += 1 759 if results:
683 if count == 100: 760 if len(results) < 20:
684 count = 0 761 msg = self.tr("%n package(s) found.", "", len(results))
685 QApplication.processEvents()
686 else: 762 else:
687 E5MessageBox.warning( 763 msg = self.tr("Showing first 20 packages found.")
688 self, 764 self.searchInfoLabel.setText(msg)
689 self.tr("Search PyPI"),
690 self.tr("""<p>The package search did not return"""
691 """ anything.</p>"""))
692 self.searchInfoLabel.setText(
693 self.tr("""<p>The package search did not return"""
694 """ anything.</p>"""))
695 else: 765 else:
696 E5MessageBox.warning( 766 E5MessageBox.warning(
697 self, 767 self,
698 self.tr("Search PyPI"), 768 self.tr("Search PyPI"),
699 self.tr("""<p>The package search did not return anything.""" 769 self.tr("""<p>There were no results for <b>{0}</b>.</p>"""))
700 """</p>"""))
701 self.searchInfoLabel.setText( 770 self.searchInfoLabel.setText(
702 self.tr("""<p>The package search did not return anything.""" 771 self.tr("""<p>There were no results for <b>{0}</b>.</p>"""))
703 """</p>""")) 772
773 wrapper = textwrap.TextWrapper(width=80)
774 for result in results:
775 try:
776 description = "\n".join([
777 wrapper.fill(line) for line in
778 result['description'].strip().splitlines()
779 ])
780 except KeyError:
781 description = ""
782 itm = QTreeWidgetItem(
783 self.searchResultList, [
784 result['name'].strip(),
785 result["released"].strip(),
786 description,
787 ])
788 itm.setData(0, self.SearchVersionRole, result['version'])
704 789
705 header = self.searchResultList.header() 790 header = self.searchResultList.header()
706 self.searchResultList.sortItems(1, Qt.DescendingOrder)
707 header.setStretchLastSection(False) 791 header.setStretchLastSection(False)
708 header.resizeSections(QHeaderView.ResizeToContents) 792 header.resizeSections(QHeaderView.ResizeToContents)
709 headerSize = 0 793 headerSize = 0
710 for col in range(header.count()): 794 for col in range(header.count()):
711 headerSize += header.sectionSize(col) 795 headerSize += header.sectionSize(col)
721 self.__updateSearchActionButtons() 805 self.__updateSearchActionButtons()
722 self.__updateSearchButton() 806 self.__updateSearchButton()
723 807
724 self.searchEditName.setFocus(Qt.OtherFocusReason) 808 self.searchEditName.setFocus(Qt.OtherFocusReason)
725 809
726 def __searchError(self, errorCode, errorString):
727 """
728 Private method handling a search error.
729
730 @param errorCode code of the error
731 @type int
732 @param errorString error message
733 @type str
734 """
735 self.__finishSearch()
736 E5MessageBox.warning(
737 self,
738 self.tr("Search PyPI"),
739 self.tr("""<p>The package search failed.</p><p>Reason: {0}</p>""")
740 .format(errorString))
741 self.searchInfoLabel.setText(self.tr("Error: {0}").format(errorString))
742
743 def __transformHits(self, hits):
744 """
745 Private method to convert the list returned from pypi into a
746 packages list.
747
748 @param hits list returned from pypi
749 @type list of dict
750 @return list of packages
751 @rtype list of dict
752 """
753 # we only include the record with the highest score
754 packages = {}
755 for hit in hits:
756 name = hit['name'].strip()
757 summary = (hit['summary'] or "").strip()
758 version = hit['version'].strip()
759 score = self.__score(name, summary)
760 # cleanup the summary
761 if summary in ["UNKNOWN", "."]:
762 summary = ""
763
764 if name not in packages:
765 packages[name] = {
766 'name': name,
767 'summary': summary,
768 'version': [version.strip()],
769 'score': score}
770 else:
771 if score > packages[name]['score']:
772 packages[name]['score'] = score
773 packages[name]['summary'] = summary
774 packages[name]['version'].append(version.strip())
775
776 return list(packages.values())
777
778 def __score(self, name, summary):
779 """
780 Private method to calculate some score for a search result.
781
782 @param name name of the returned package
783 @type str
784 @param summary summary text for the package
785 @type str
786 @return score value
787 @rtype int
788 """
789 score = 0
790 for queryTerm in self.__queryName:
791 if queryTerm.lower() in name.lower():
792 score += 4
793 if queryTerm.lower() == name.lower():
794 score += 4
795
796 for queryTerm in self.__querySummary:
797 if queryTerm.lower() in summary.lower():
798 if re.search(r'\b{0}\b'.format(re.escape(queryTerm)),
799 summary, re.IGNORECASE) is not None:
800 # word match gets even higher score
801 score += 2
802 else:
803 score += 1
804
805 return score
806
807 @pyqtSlot() 810 @pyqtSlot()
808 def on_installButton_clicked(self): 811 def on_installButton_clicked(self):
809 """ 812 """
810 Private slot to handle pressing the Install button.. 813 Private slot to handle pressing the Install button..
811 """ 814 """
864 self.showDetailsButton.setEnabled(False) 867 self.showDetailsButton.setEnabled(False)
865 868
866 if not item: 869 if not item:
867 item = self.searchResultList.selectedItems()[0] 870 item = self.searchResultList.selectedItems()[0]
868 871
869 packageVersions = item.data(0, self.SearchVersionRole) 872 packageVersion = item.data(0, self.SearchVersionRole)
870 if len(packageVersions) == 1:
871 packageVersion = packageVersions[0]
872 elif len(packageVersions) == 0:
873 packageVersion = ""
874 else:
875 packageVersion, ok = QInputDialog.getItem(
876 self,
877 self.tr("Show Package Details"),
878 self.tr("Select the package version:"),
879 packageVersions,
880 0, False)
881 if not ok:
882 return
883 packageName = item.text(0) 873 packageName = item.text(0)
884 874
885 self.__showPackageDetails(packageName, packageVersion) 875 self.__showPackageDetails(packageName, packageVersion)
886 876
887 def __showPackageDetails(self, packageName, packageVersion): 877 def __showPackageDetails(self, packageName, packageVersion):

eric ide

mercurial