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 |
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 """ |