1# -*- coding: utf-8 -*-
2
3import logging
4import os
5import re
6import subprocess
7import sys
8import tkinter as tk
9from logging import exception
10from os import makedirs
11from tkinter import messagebox, ttk
12from tkinter.messagebox import showerror
13from typing import List, Union, Dict, Tuple
14
15import thonny
16from thonny import get_runner, get_workbench, running, tktextext, ui_utils
17from thonny.common import InlineCommand, is_same_path, normpath_with_actual_case, path_startswith
18from thonny.languages import tr
19from thonny.plugins.cpython import CPythonProxy
20from thonny.plugins.cpython_ssh import SshCPythonProxy
21from thonny.running import get_interpreter_for_subprocess, InlineCommandDialog
22from thonny.ui_utils import (
23    AutoScrollbar,
24    CommonDialog,
25    askopenfilename,
26    get_busy_cursor,
27    lookup_style_option,
28    open_path_in_system_file_manager,
29    scrollbar_style,
30    ems_to_pixels,
31)
32from thonny.workdlg import SubprocessDialog
33
34PIP_INSTALLER_URL = "https://bootstrap.pypa.io/get-pip.py"
35
36logger = logging.getLogger(__name__)
37
38
39class PipDialog(CommonDialog):
40    def __init__(self, master):
41        self._state = "idle"  # possible values: "listing", "fetching", "idle"
42        self._process = None
43        self._closed = False
44        self._active_distributions = {}
45        self.current_package_data = None
46
47        super().__init__(master)
48
49        main_frame = ttk.Frame(self)
50        main_frame.grid(sticky=tk.NSEW, ipadx=15, ipady=15)
51        self.rowconfigure(0, weight=1)
52        self.columnconfigure(0, weight=1)
53
54        self.title(self._get_title())
55
56        self._create_widgets(main_frame)
57
58        self.search_box.focus_set()
59
60        self.bind("<Escape>", self._on_close, True)
61        self.protocol("WM_DELETE_WINDOW", self._on_close)
62        self._show_instructions()
63
64        self._start_update_list()
65
66    def get_search_button_text(self):
67        return tr("Search on PyPI")
68
69    def get_install_button_text(self):
70        return tr("Install")
71
72    def get_upgrade_button_text(self):
73        return tr("Upgrade")
74
75    def get_uninstall_button_text(self):
76        return tr("Uninstall")
77
78    def get_delete_selected_button_text(self):
79        return tr("Delete selected")
80
81    def _create_widgets(self, parent):
82
83        header_frame = ttk.Frame(parent)
84        header_frame.grid(row=1, column=0, sticky="nsew", padx=15, pady=(15, 0))
85        header_frame.columnconfigure(0, weight=1)
86        header_frame.rowconfigure(1, weight=1)
87
88        name_font = tk.font.nametofont("TkDefaultFont").copy()
89        name_font.configure(size=16)
90        self.search_box = ttk.Entry(header_frame)
91        self.search_box.grid(row=1, column=0, sticky="nsew")
92        self.search_box.bind("<Return>", self._on_search, False)
93        self.search_box.bind("<KP_Enter>", self._on_search, False)
94
95        # Selecting chars in the search box with mouse didn't make the box active on Linux without following line
96        self.search_box.bind("<B1-Motion>", lambda _: self.search_box.focus_set())
97
98        self.search_button = ttk.Button(
99            header_frame, text=self.get_search_button_text(), command=self._on_search, width=25
100        )
101        self.search_button.grid(row=1, column=1, sticky="nse", padx=(10, 0))
102
103        main_pw = tk.PanedWindow(
104            parent,
105            orient=tk.HORIZONTAL,
106            background=lookup_style_option("TPanedWindow", "background"),
107            sashwidth=15,
108        )
109        main_pw.grid(row=2, column=0, sticky="nsew", padx=15, pady=15)
110        parent.rowconfigure(2, weight=1)
111        parent.columnconfigure(0, weight=1)
112
113        listframe = ttk.Frame(main_pw, relief="flat", borderwidth=1)
114        listframe.rowconfigure(0, weight=1)
115        listframe.columnconfigure(0, weight=1)
116
117        self.listbox = ui_utils.ThemedListbox(
118            listframe,
119            activestyle="dotbox",
120            width=20,
121            height=20,
122            selectborderwidth=0,
123            relief="flat",
124            # highlightthickness=4,
125            # highlightbackground="red",
126            # highlightcolor="green",
127            borderwidth=0,
128        )
129        self.listbox.insert("end", " <" + tr("INSTALL") + ">")
130        self.listbox.bind("<<ListboxSelect>>", self._on_listbox_select, True)
131        self.listbox.grid(row=0, column=0, sticky="nsew")
132        list_scrollbar = AutoScrollbar(
133            listframe, orient=tk.VERTICAL, style=scrollbar_style("Vertical")
134        )
135        list_scrollbar.grid(row=0, column=1, sticky="ns")
136        list_scrollbar["command"] = self.listbox.yview
137        self.listbox["yscrollcommand"] = list_scrollbar.set
138
139        info_frame = ttk.Frame(main_pw)
140        info_frame.columnconfigure(0, weight=1)
141        info_frame.rowconfigure(1, weight=1)
142
143        main_pw.add(listframe)
144        main_pw.add(info_frame)
145
146        self.title_label = ttk.Label(info_frame, text="", font=name_font)
147        self.title_label.grid(row=0, column=0, sticky="w", padx=5, pady=(0, ems_to_pixels(1)))
148
149        info_text_frame = tktextext.TextFrame(
150            info_frame,
151            read_only=True,
152            horizontal_scrollbar=False,
153            background=lookup_style_option("TFrame", "background"),
154            vertical_scrollbar_class=AutoScrollbar,
155            vertical_scrollbar_style=scrollbar_style("Vertical"),
156            horizontal_scrollbar_style=scrollbar_style("Horizontal"),
157            width=70,
158            height=10,
159        )
160        info_text_frame.configure(borderwidth=0)
161        info_text_frame.grid(row=1, column=0, columnspan=4, sticky="nsew", pady=(0, 10))
162        self.info_text = info_text_frame.text
163        link_color = lookup_style_option("Url.TLabel", "foreground", "red")
164        self.info_text.tag_configure("url", foreground=link_color, underline=True)
165        self.info_text.tag_bind("url", "<ButtonRelease-1>", self._handle_url_click)
166        self.info_text.tag_bind("url", "<Enter>", lambda e: self.info_text.config(cursor="hand2"))
167        self.info_text.tag_bind("url", "<Leave>", lambda e: self.info_text.config(cursor=""))
168        self.info_text.tag_configure("install_reqs", foreground=link_color, underline=True)
169        self.info_text.tag_bind(
170            "install_reqs", "<ButtonRelease-1>", self._handle_install_requirements_click
171        )
172        self.info_text.tag_bind(
173            "install_reqs", "<Enter>", lambda e: self.info_text.config(cursor="hand2")
174        )
175        self.info_text.tag_bind(
176            "install_reqs", "<Leave>", lambda e: self.info_text.config(cursor="")
177        )
178        self.info_text.tag_configure("install_file", foreground=link_color, underline=True)
179        self.info_text.tag_bind(
180            "install_file", "<ButtonRelease-1>", self._handle_install_file_click
181        )
182        self.info_text.tag_bind(
183            "install_file", "<Enter>", lambda e: self.info_text.config(cursor="hand2")
184        )
185        self.info_text.tag_bind(
186            "install_file", "<Leave>", lambda e: self.info_text.config(cursor="")
187        )
188
189        default_font = tk.font.nametofont("TkDefaultFont")
190        self.info_text.configure(font=default_font, wrap="word")
191
192        bold_font = default_font.copy()
193        # need to explicitly copy size, because Tk 8.6 on certain Ubuntus use bigger font in copies
194        bold_font.configure(weight="bold", size=default_font.cget("size"))
195        self.info_text.tag_configure("caption", font=bold_font)
196        self.info_text.tag_configure("bold", font=bold_font)
197
198        self.command_frame = ttk.Frame(info_frame)
199        self.command_frame.grid(row=2, column=0, sticky="w")
200
201        self.install_button = ttk.Button(
202            self.command_frame,
203            text=" " + self.get_upgrade_button_text() + " ",
204            command=self._on_install_click,
205            width=20,
206        )
207
208        self.install_button.grid(row=0, column=0, sticky="w", padx=0)
209
210        self.uninstall_button = ttk.Button(
211            self.command_frame,
212            text=self.get_uninstall_button_text(),
213            command=self._on_uninstall_click,
214            width=20,
215        )
216
217        self.uninstall_button.grid(row=0, column=1, sticky="w", padx=(5, 0))
218
219        self.advanced_button = ttk.Button(
220            self.command_frame,
221            text="...",
222            width=3,
223            command=lambda: self._perform_pip_action("advanced"),
224        )
225
226        self.advanced_button.grid(row=0, column=2, sticky="w", padx=(5, 0))
227
228        self.close_button = ttk.Button(info_frame, text=tr("Close"), command=self._on_close)
229        self.close_button.grid(row=2, column=3, sticky="e")
230
231    def _set_state(self, state, force_normal_cursor=False):
232        self._state = state
233        action_buttons = [
234            self.install_button,
235            self.advanced_button,
236            self.uninstall_button,
237        ]
238
239        other_widgets = [
240            self.listbox,
241            # self.search_box, # looks funny when disabled
242            self.search_button,
243        ]
244
245        if state == "idle" and not self._read_only():
246            for widget in action_buttons:
247                widget["state"] = tk.NORMAL
248        else:
249            for widget in action_buttons:
250                widget["state"] = tk.DISABLED
251
252        if state == "idle":
253            for widget in other_widgets:
254                widget["state"] = tk.NORMAL
255        else:
256            self.config(cursor=get_busy_cursor())
257            for widget in other_widgets:
258                widget["state"] = tk.DISABLED
259
260        if state == "idle" or force_normal_cursor:
261            self.config(cursor="")
262        else:
263            self.config(cursor=get_busy_cursor())
264
265    def _get_state(self):
266        return self._state
267
268    def _instructions_for_command_line_install(self):
269        return (
270            "Alternatively, if you have an older pip installed, then you can install packages "
271            + "on the command line (Tools → Open system shell...)"
272        )
273
274    def _start_update_list(self, name_to_show=None):
275        raise NotImplementedError()
276
277    def _update_list(self, name_to_show):
278        self.listbox.delete(1, "end")
279        for name in sorted(self._active_distributions.keys()):
280            self.listbox.insert("end", " " + name)
281
282        if name_to_show is None or name_to_show not in self._active_distributions.keys():
283            self._show_instructions()
284        else:
285            self._on_listbox_select_package(name_to_show)
286
287    def _on_listbox_select(self, event):
288        self.listbox.focus_set()
289        selection = self.listbox.curselection()
290        if len(selection) == 1:
291            self.listbox.activate(selection[0])
292            if selection[0] == 0:  # special first item
293                self._show_instructions()
294            else:
295                self._on_listbox_select_package(self.listbox.get(selection[0]).strip())
296
297    def _on_listbox_select_package(self, name):
298        self._start_show_package_info(name)
299
300    def _on_search(self, event=None):
301        if self._get_state() != "idle":
302            # Search box is not made inactive for busy-states
303            return
304
305        if self.search_box.get().strip() == "":
306            return
307
308        self._start_search(self.search_box.get().strip())
309
310    def _on_install_click(self):
311        self._perform_pip_action("install")
312
313    def _on_uninstall_click(self):
314        self._perform_pip_action("uninstall")
315
316    def _clear(self):
317        self.current_package_data = None
318        self.title_label.grid_remove()
319        self.command_frame.grid_remove()
320        self._clear_info_text()
321
322    def _clear_info_text(self):
323        self.info_text.direct_delete("1.0", "end")
324
325    def _append_info_text(self, text, tags=()):
326        self.info_text.direct_insert("end", text, tags)
327
328    def _show_instructions(self):
329        self._clear()
330        if self._read_only():
331            self._show_read_only_instructions()
332        else:
333            self._append_info_text(tr("Install from PyPI") + "\n", ("caption",))
334            self.info_text.direct_insert(
335                "end",
336                tr(
337                    "If you don't know where to get the package from, "
338                    + "then most likely you'll want to search the Python Package Index. "
339                    + "Start by entering the name of the package in the search box above and pressing ENTER."
340                )
341                + "\n\n",
342            )
343
344            self.info_text.direct_insert(
345                "end", tr("Install from requirements file") + "\n", ("caption",)
346            )
347            self._append_info_text(tr("Click" + " "))
348            self._append_info_text(tr("here"), ("install_reqs",))
349            self.info_text.direct_insert(
350                "end",
351                " "
352                + tr("to locate requirements.txt file and install the packages specified in it.")
353                + "\n\n",
354            )
355
356            self._show_instructions_about_installing_from_local_file()
357            self._show_instructions_about_existing_packages()
358
359            if self._get_target_directory():
360                self._show_instructions_about_target()
361
362        self._select_list_item(0)
363
364    def _show_read_only_instructions(self):
365        self._append_info_text(tr("Browse the packages") + "\n", ("caption",))
366        self.info_text.direct_insert(
367            "end",
368            tr(
369                "With current interpreter you can only browse the packages here.\n"
370                + "Use 'Tools → Open system shell...' for installing, upgrading or uninstalling."
371            )
372            + "\n\n",
373        )
374
375        if self._get_target_directory():
376            self._append_info_text(tr("Packages' directory") + "\n", ("caption",))
377            self.info_text.direct_insert("end", self._get_target_directory(), ("target_directory"))
378
379    def _show_instructions_about_installing_from_local_file(self):
380        self._append_info_text(tr("Install from local file") + "\n", ("caption",))
381        self._append_info_text(tr("Click") + " ")
382        self._append_info_text(tr("here"), ("install_file",))
383        self.info_text.direct_insert(
384            "end",
385            " "
386            + tr(
387                "to locate and install the package file (usually with .whl, .tar.gz or .zip extension)."
388            )
389            + "\n\n",
390        )
391
392    def _show_instructions_about_existing_packages(self):
393        self._append_info_text(tr("Upgrade or uninstall") + "\n", ("caption",))
394        self.info_text.direct_insert(
395            "end", tr("Start by selecting the package from the left.") + "\n\n"
396        )
397
398    def _show_instructions_about_target(self):
399        self._append_info_text(tr("Target:") + "  ", ("caption",))
400        if self._should_install_to_site_packages():
401            self.info_text.direct_insert("end", tr("virtual environment") + "\n", ("caption",))
402        else:
403            self.info_text.direct_insert("end", tr("user site packages") + "\n", ("caption",))
404
405        self.info_text.direct_insert(
406            "end",
407            tr(
408                "This dialog lists all available packages,"
409                + " but allows upgrading and uninstalling only packages from"
410            )
411            + " ",
412        )
413        self._append_info_text(self._get_target_directory(), ("url"))
414        self.info_text.direct_insert(
415            "end",
416            ". "
417            + tr(
418                "New packages will be also installed into this directory."
419                + " Other locations must be managed by alternative means."
420            ),
421        )
422
423    def _start_show_package_info(self, name):
424        self.current_package_data = None
425        # Fetch info from PyPI
426        self._set_state("fetching")
427        # Following fetches info about latest version.
428        # This is OK even when we're looking an installed older version
429        # because new version may have more relevant and complete info.
430        _start_fetching_package_info(name, None, self._show_package_info)
431
432        self._clear_info_text()
433        self.title_label["text"] = ""
434        self.title_label.grid()
435        self.command_frame.grid()
436        self.uninstall_button["text"] = self.get_uninstall_button_text()
437
438        active_dist = self._get_active_dist(name)
439        if active_dist is not None:
440            self.title_label["text"] = active_dist["project_name"]
441            self._append_info_text(tr("Installed version:") + " ", ("caption",))
442            self._append_info_text(active_dist["version"] + "\n")
443            self._append_info_text(tr("Installed to:") + " ", ("caption",))
444            # TODO: only show link if local backend
445            self.info_text.direct_insert(
446                "end", normpath_with_actual_case(active_dist["location"]), ("url",)
447            )
448            self._append_info_text("\n\n")
449            self._select_list_item(name)
450        else:
451            self._select_list_item(0)
452
453        # update gui
454        if self._is_read_only_package(name):
455            self.install_button.grid_remove()
456            self.uninstall_button.grid_remove()
457            self.advanced_button.grid_remove()
458        else:
459            self.install_button.grid(row=0, column=0)
460            self.advanced_button.grid(row=0, column=2)
461
462            if active_dist is not None:
463                # existing package in target directory
464                self.install_button["text"] = self.get_upgrade_button_text()
465                self.install_button["state"] = "disabled"
466                self.uninstall_button.grid(row=0, column=1)
467            else:
468                # new package
469                self.install_button["text"] = self.get_install_button_text()
470                self.uninstall_button.grid_remove()
471
472    def _show_package_info(self, name, data, error_code=None):
473        self._set_state("idle")
474
475        self.current_package_data = data
476
477        def write(s, tag=None):
478            if tag is None:
479                tags = ()
480            else:
481                tags = (tag,)
482            self._append_info_text(s, tags)
483
484        def write_att(caption, value, value_tag=None):
485            write(caption + ": ", "caption")
486            write(value, value_tag)
487            write("\n")
488
489        if error_code is not None:
490            if error_code == 404:
491                write(tr("Could not find the package from PyPI."))
492                if not self._get_active_version(name):
493                    # new package
494                    write("\n" + tr("Please check your spelling!"))
495
496            else:
497                write(
498                    tr("Could not find the package info from PyPI.")
499                    + " "
500                    + tr("Error code:")
501                    + " "
502                    + str(error_code)
503                )
504
505            return
506
507        info = data["info"]
508        self.title_label["text"] = info["name"]  # search name could have been a bit different
509        latest_stable_version = _get_latest_stable_version(data["releases"].keys())
510        if latest_stable_version is not None:
511            write_att(tr("Latest stable version"), latest_stable_version)
512        else:
513            write_att(tr("Latest version"), data["info"]["version"])
514        write_att(tr("Summary"), info["summary"])
515        write_att(tr("Author"), info["author"])
516        write_att(tr("Homepage"), info["home_page"], "url")
517        if info.get("bugtrack_url", None):
518            write_att(tr("Bugtracker"), info["bugtrack_url"], "url")
519        if info.get("docs_url", None):
520            write_att(tr("Documentation"), info["docs_url"], "url")
521        if info.get("package_url", None):
522            write_att(tr("PyPI page"), info["package_url"], "url")
523        if info.get("requires_dist", None):
524            # Available only when release is created by a binary wheel
525            # https://github.com/pypa/pypi-legacy/issues/622#issuecomment-305829257
526            write_att(tr("Requires"), ", ".join(info["requires_dist"]))
527
528        if self._get_active_version(name) != latest_stable_version or not self._get_active_version(
529            name
530        ):
531            self.install_button["state"] = "normal"
532        else:
533            self.install_button["state"] = "disabled"
534
535    def _is_read_only_package(self, name):
536        dist = self._get_active_dist(name)
537        if dist is None:
538            return False
539        else:
540            return normpath_with_actual_case(dist["location"]) != self._get_target_directory()
541
542    def _normalize_name(self, name):
543        # looks like (in some cases?) pip list gives the name as it was used during install
544        # ie. the list may contain lowercase entry, when actual metadata has uppercase name
545        # Example: when you "pip install cx-freeze", then "pip list"
546        # really returns "cx-freeze" although correct name is "cx_Freeze"
547
548        # https://www.python.org/dev/peps/pep-0503/#id4
549        return re.sub(r"[-_.]+", "-", name).lower().strip()
550
551    def _start_search(self, query, discard_selection=True):
552        self.current_package_data = None
553        # Fetch info from PyPI
554        self._set_state("fetching")
555        self._clear()
556        self.title_label.grid()
557        self.title_label["text"] = tr("Search results")
558        self.info_text.direct_insert("1.0", tr("Searching") + " ...")
559        _start_fetching_search_results(query, self._show_search_results)
560        if discard_selection:
561            self._select_list_item(0)
562
563    def _show_search_results(self, query, results: Union[List[Dict], str]) -> None:
564        self._set_state("idle")
565        self._clear_info_text()
566
567        results = self._tweak_search_results(results, query)
568
569        if isinstance(results, str) or not results:
570            if not results:
571                self._append_info_text("No results.\n\n")
572            else:
573                self._append_info_text("Could not fetch search results:\n")
574                self._append_info_text(results + "\n\n")
575
576            self._append_info_text("Try opening the package directly:\n")
577            self._append_info_text(query, ("url",))
578            return
579
580        for item in results:
581            # self._append_info_text("•")
582            tags = ("url",)
583            if item["name"].lower() == query.lower():
584                tags = tags + ("bold",)
585
586            self._append_info_text(item["name"], tags)
587            self._append_info_text("\n")
588            self.info_text.direct_insert(
589                "end", item.get("description", "<No description>").strip() + "\n"
590            )
591            self._append_info_text("\n")
592
593    def _select_list_item(self, name_or_index):
594        if isinstance(name_or_index, int):
595            index = name_or_index
596        else:
597            normalized_items = list(map(self._normalize_name, self.listbox.get(0, "end")))
598            try:
599                index = normalized_items.index(self._normalize_name(name_or_index))
600            except Exception:
601                exception(tr("Can't find package name from the list:") + " " + name_or_index)
602                return
603
604        old_state = self.listbox["state"]
605        try:
606            self.listbox["state"] = "normal"
607            self.listbox.select_clear(0, "end")
608            self.listbox.select_set(index)
609            self.listbox.activate(index)
610            self.listbox.see(index)
611        finally:
612            self.listbox["state"] = old_state
613
614    def _get_install_command(self):
615        cmd = ["install", "--no-cache-dir"]
616        if self._use_user_install():
617            cmd.append("--user")
618        return cmd
619
620    def _perform_pip_action(self, action: str) -> bool:
621        if self._perform_pip_action_without_refresh(action):
622            if action == "uninstall":
623                self._show_instructions()  # Make the old package go away as fast as possible
624            self._start_update_list(
625                None if action == "uninstall" else self.current_package_data["info"]["name"]
626            )
627
628            get_workbench().event_generate("RemoteFilesChanged")
629
630    def _perform_pip_action_without_refresh(self, action: str) -> bool:
631        assert self._get_state() == "idle"
632        assert self.current_package_data is not None
633        data = self.current_package_data
634        name = self.current_package_data["info"]["name"]
635
636        install_cmd = self._get_install_command()
637
638        if action == "install":
639            title = tr("Installing '%s'") % name
640            if not self._confirm_install(self.current_package_data):
641                return False
642
643            args = install_cmd
644            if self._get_active_version(name) is not None:
645                title = tr("Upgrading '%s'") % name
646                args.append("--upgrade")
647
648            args.append(name)
649        elif action == "uninstall":
650            title = tr("Uninstalling '%s'") % name
651            if name in ["pip", "setuptools"] and not messagebox.askyesno(
652                tr("Really uninstall?"),
653                tr(
654                    "Package '{}' is required for installing and uninstalling other packages."
655                ).format(name)
656                + "\n\n"
657                + tr("Are you sure you want to uninstall it?"),
658                master=self,
659            ):
660                return False
661            args = ["uninstall", "-y", name]
662        elif action == "advanced":
663            title = tr("Installing")
664            details = _ask_installation_details(
665                self,
666                data,
667                _get_latest_stable_version(list(data["releases"].keys())),
668                self.does_support_update_deps_switch(),
669            )
670            if details is None:  # Cancel
671                return False
672
673            version, package_data, upgrade_deps = details
674            if not self._confirm_install(package_data):
675                return False
676
677            args = install_cmd
678            if upgrade_deps:
679                args.append("--upgrade")
680            args.append(name + "==" + version)
681        else:
682            raise RuntimeError("Unknown action")
683
684        returncode, _, _ = self._run_pip_with_dialog(args, title=title)
685        return returncode == 0
686
687    def does_support_update_deps_switch(self):
688        return True
689
690    def _handle_install_file_click(self, event):
691        if self._get_state() != "idle":
692            return
693
694        filename = askopenfilename(
695            master=self,
696            filetypes=[(tr("Package"), ".whl .zip .tar.gz"), (tr("all files"), ".*")],
697            initialdir=get_workbench().get_local_cwd(),
698            parent=self.winfo_toplevel(),
699        )
700        if filename:  # Note that missing filename may be "" or () depending on tkinter version
701            self._install_file(filename, False)
702
703    def _handle_install_requirements_click(self, event):
704        if self._get_state() != "idle":
705            return
706
707        filename = askopenfilename(
708            master=self,
709            filetypes=[("requirements", ".txt"), (tr("all files"), ".*")],
710            initialdir=get_workbench().get_local_cwd(),
711            parent=self.winfo_toplevel(),
712        )
713        if filename:  # Note that missing filename may be "" or () depending on tkinter version
714            self._install_file(filename, True)
715
716    def _handle_target_directory_click(self, event):
717        if self._get_target_directory():
718            open_path_in_system_file_manager(self._get_target_directory())
719
720    def _install_file(self, filename, is_requirements_file):
721        args = self._get_install_file_command(filename, is_requirements_file)
722
723        returncode, out, err = self._run_pip_with_dialog(
724            args, title=tr("Installing '%s'") % os.path.basename(filename)
725        )
726
727        # Try to find out the name of the package we're installing
728        name = None
729
730        # output should include a line like this:
731        # Installing collected packages: pytz, six, python-dateutil, numpy, pandas
732        inst_lines = re.findall(
733            "^Installing collected packages:.*?$", out, re.MULTILINE | re.IGNORECASE
734        )  # @UndefinedVariable
735        if len(inst_lines) == 1:
736            # take last element
737            elements = re.split(",|:", inst_lines[0])
738            name = elements[-1].strip()
739
740        self._start_update_list(name)
741
742    def _get_install_file_command(self, filename, is_requirements_file):
743        args = ["install"]
744        if self._use_user_install():
745            args.append("--user")
746        if is_requirements_file:
747            args.append("-r")
748        args.append(filename)
749
750        return args
751
752    def _handle_url_click(self, event):
753        url = _extract_click_text(self.info_text, event, "url")
754        if url is not None:
755            if url.startswith("http:") or url.startswith("https:"):
756                import webbrowser
757
758                webbrowser.open(url)
759            elif os.path.sep in url:
760                os.makedirs(url, exist_ok=True)
761                open_path_in_system_file_manager(url)
762            else:
763                self._start_show_package_info(url)
764
765    def _on_close(self, event=None):
766        self._closed = True
767        self.destroy()
768
769    def _get_active_version(self, name):
770        dist = self._get_active_dist(name)
771        if dist is None:
772            return None
773        else:
774            return dist["version"]
775
776    def _get_active_dist(self, name):
777        normname = self._normalize_name(name)
778        for key in self._active_distributions:
779
780            if self._normalize_name(key) == normname:
781                return self._active_distributions[key]
782
783        return None
784
785    def _run_pip_with_dialog(self, args, title) -> Tuple[int, str, str]:
786        raise NotImplementedError()
787
788    def _get_interpreter(self):
789        raise NotImplementedError()
790
791    def _should_install_to_site_packages(self):
792        raise NotImplementedError()
793
794    def _use_user_install(self):
795        return not self._should_install_to_site_packages()
796
797    def _get_target_directory(self):
798        raise NotImplementedError()
799
800    def _get_title(self):
801        return tr("Manage packages for %s") % self._get_interpreter()
802
803    def _confirm_install(self, package_data):
804        return True
805
806    def _read_only(self):
807        if self._should_install_to_site_packages():
808            return False
809        else:
810            # readonly if not in a virtual environment
811            # and user site packages is disabled
812            import site
813
814            return not site.ENABLE_USER_SITE
815
816    def _tweak_search_results(self, results, query):
817        return results
818
819    def _get_extra_switches(self):
820        result = ["--disable-pip-version-check"]
821        proxy = os.environ.get("https_proxy", os.environ.get("http_proxy", None))
822        if proxy:
823            result.append("--proxy=" + proxy)
824
825        return result
826
827
828class BackendPipDialog(PipDialog):
829    def __init__(self, master):
830        self._backend_proxy = get_runner().get_backend_proxy()
831        super().__init__(master)
832
833        self._last_name_to_show = None
834
835    def _start_update_list(self, name_to_show=None):
836        assert self._get_state() in [None, "idle"]
837        self._set_state("listing")
838
839        get_workbench().bind("get_active_distributions_response", self._complete_update_list, True)
840        self._last_name_to_show = name_to_show
841        logger.debug("Sending get_active_distributions")
842        get_runner().send_command(InlineCommand("get_active_distributions"))
843
844    def _complete_update_list(self, msg):
845        if self._closed:
846            return
847
848        get_workbench().unbind("get_active_distributions_response", self._complete_update_list)
849        if "error" in msg:
850            self._clear_info_text()
851            self.info_text.direct_insert("1.0", msg["error"])
852            self._set_state("idle", True)
853            return
854
855        self._active_distributions = msg.distributions
856        self._set_state("idle", True)
857        self._update_list(self._last_name_to_show)
858
859
860class CPythonBackendPipDialog(BackendPipDialog):
861    def __init__(self, master):
862        super().__init__(master)
863        assert isinstance(self._backend_proxy, (CPythonProxy, SshCPythonProxy))
864
865    def _get_interpreter(self):
866        return get_runner().get_local_executable()
867
868    def _create_python_process(self, args):
869        proc = running.create_backend_python_process(args, stderr=subprocess.STDOUT)
870        return proc, proc.cmd
871
872    def _confirm_install(self, package_data):
873        name = package_data["info"]["name"]
874
875        if name.lower().startswith("thonny"):
876            return messagebox.askyesno(
877                tr("Confirmation"),
878                tr(
879                    "Looks like you are installing a Thonny-related package.\n"
880                    + "If you meant to install a Thonny plugin, then you should\n"
881                    + "choose 'Tools → Manage plugins...' instead\n"
882                    + "\n"
883                    + "Are you sure you want to install %s for the back-end?"
884                )
885                % name,
886                master=self,
887            )
888        else:
889            return True
890
891    def _get_target_directory(self):
892        if self._should_install_to_site_packages():
893            return normpath_with_actual_case(self._backend_proxy.get_site_packages())
894        else:
895            usp = self._backend_proxy.get_user_site_packages()
896            if isinstance(self._backend_proxy, CPythonProxy):
897                os.makedirs(usp, exist_ok=True)
898                return normpath_with_actual_case(usp)
899            else:
900                return usp
901
902    def _should_install_to_site_packages(self):
903        return self._targets_virtual_environment()
904
905    def _targets_virtual_environment(self):
906        return get_runner().using_venv()
907
908    def _run_pip_with_dialog(self, args, title) -> Tuple[int, str, str]:
909        proxy = get_runner().get_backend_proxy()
910        assert isinstance(proxy, CPythonProxy)
911        sub_cmd = [proxy._reported_executable, "-m", "pip"] + args + self._get_extra_switches()
912        back_cmd = InlineCommand("execute_system_command", cmd_line=sub_cmd)
913        dlg = InlineCommandDialog(
914            self,
915            back_cmd,
916            title="pip",
917            instructions=title,
918            autostart=True,
919            output_prelude=subprocess.list2cmdline(sub_cmd) + "\n\n",
920        )
921        ui_utils.show_dialog(dlg)
922
923        return dlg.returncode, dlg.stdout, dlg.stderr
924
925
926class PluginsPipDialog(PipDialog):
927    def __init__(self, master):
928        PipDialog.__init__(self, master)
929
930        # make sure directory exists, so user can put her plug-ins there
931        d = self._get_target_directory()
932        makedirs(d, exist_ok=True)
933
934    def _start_update_list(self, name_to_show=None):
935        assert self._get_state() in [None, "idle"]
936        import pkg_resources
937
938        pkg_resources._initialize_master_working_set()
939
940        self._active_distributions = {
941            dist.key: {
942                "project_name": dist.project_name,
943                "key": dist.key,
944                "location": dist.location,
945                "version": dist.version,
946            }
947            for dist in pkg_resources.working_set  # pylint: disable=not-an-iterable
948        }
949
950        self._update_list(name_to_show)
951
952    def _conflicts_with_thonny_version(self, req_strings):
953        import pkg_resources
954
955        try:
956            conflicts = []
957            for req_string in req_strings:
958                req = pkg_resources.Requirement.parse(req_string)
959                if req.project_name == "thonny" and thonny.get_version() not in req:
960                    conflicts.append(req_string)
961
962            return conflicts
963        except Exception:
964            logging.exception("Problem computing conflicts")
965            return None
966
967    def _get_interpreter(self):
968        return get_interpreter_for_subprocess(sys.executable)
969
970    def _should_install_to_site_packages(self):
971        return self._targets_virtual_environment()
972
973    def _targets_virtual_environment(self):
974        # https://stackoverflow.com/a/42580137/261181
975        return (
976            hasattr(sys, "base_prefix")
977            and sys.base_prefix != sys.prefix
978            or hasattr(sys, "real_prefix")
979            and getattr(sys, "real_prefix") != sys.prefix
980        )
981
982    def _confirm_install(self, package_data):
983        name = package_data["info"]["name"]
984        reqs = package_data["info"].get("requires_dist", None)
985
986        other_version_text = tr(
987            "NB! There may be another version available "
988            + "which is compatible with current Thonny version. "
989            + "Click on '...' button to choose the version to install."
990        )
991
992        if name.lower().startswith("thonny-") and not reqs:
993            showerror(
994                tr("Thonny plugin without requirements"),
995                tr(
996                    "Looks like you are trying to install an outdated Thonny\n"
997                    + "plug-in (it doesn't specify required Thonny version\n"
998                    + "or hasn't uploaded a whl file before other files).\n\n"
999                    + "If you still want it, then please install it from the command line."
1000                )
1001                + "\n\n"
1002                + other_version_text,
1003                master=self,
1004            )
1005            return False
1006        elif reqs:
1007            conflicts = self._conflicts_with_thonny_version(reqs)
1008            if conflicts:
1009                showerror(
1010                    tr("Unsuitable requirements"),
1011                    tr("This package requires different Thonny version:")
1012                    + "\n\n  "
1013                    + "\n  ".join(conflicts)
1014                    + "\n\n"
1015                    + tr("If you still want it, then please install it from the command line.")
1016                    + "\n\n"
1017                    + other_version_text,
1018                    master=self,
1019                )
1020                return False
1021
1022        return True
1023
1024    def _get_target_directory(self):
1025        if self._use_user_install():
1026            import site
1027
1028            assert hasattr(site, "getusersitepackages")
1029            os.makedirs(site.getusersitepackages(), exist_ok=True)
1030            return normpath_with_actual_case(site.getusersitepackages())
1031        else:
1032            for d in sys.path:
1033                if ("site-packages" in d or "dist-packages" in d) and path_startswith(
1034                    d, sys.prefix
1035                ):
1036                    return normpath_with_actual_case(d)
1037            return None
1038
1039    def _create_widgets(self, parent):
1040        banner = ttk.Frame(parent, style="Tip.TFrame")
1041        banner.grid(row=0, column=0, sticky="nsew")
1042
1043        banner_msg = (
1044            tr(
1045                "This dialog is for managing Thonny plug-ins and their dependencies.\n"
1046                + "If you want to install packages for your own programs then choose 'Tools → Manage packages...'"
1047            )
1048            + "\n"
1049        )
1050
1051        runner = get_runner()
1052        if (
1053            runner is not None
1054            and runner.get_local_executable() is not None
1055            and is_same_path(self._get_interpreter(), get_runner().get_local_executable())
1056        ):
1057            banner_msg += (
1058                tr(
1059                    "(In this case Thonny's back-end uses same interpreter, so both dialogs manage same packages.)"
1060                )
1061                + "\n"
1062            )
1063
1064        banner_msg += "\n" + tr(
1065            "NB! You need to restart Thonny after installing / upgrading / uninstalling a plug-in."
1066        )
1067
1068        banner_text = ttk.Label(banner, text=banner_msg, style="Tip.TLabel", justify="left")
1069        banner_text.grid(pady=10, padx=10)
1070
1071        PipDialog._create_widgets(self, parent)
1072
1073    def _get_title(self):
1074        return tr("Thonny plug-ins")
1075
1076    def _run_pip_with_dialog(self, args, title) -> Tuple[int, str, str]:
1077        args = ["-m", "pip"] + args + self._get_extra_switches()
1078        proc = running.create_frontend_python_process(args, stderr=subprocess.STDOUT)
1079        cmd = proc.cmd
1080        dlg = SubprocessDialog(self, proc, "pip", long_description=title, autostart=True)
1081        ui_utils.show_dialog(dlg)
1082        return dlg.returncode, dlg.stdout, dlg.stderr
1083
1084
1085class DetailsDialog(CommonDialog):
1086    def __init__(self, master, package_metadata, selected_version, support_update_deps_switch):
1087        from distutils.version import StrictVersion
1088
1089        assert isinstance(master, PipDialog)
1090
1091        super().__init__(master)
1092        self.result = None
1093        self._closed = False
1094        self._version_data = None
1095        self._package_name = package_metadata["info"]["name"]
1096        self.title(tr("Advanced install / upgrade / downgrade"))
1097
1098        self.rowconfigure(0, weight=1)
1099        self.columnconfigure(0, weight=1)
1100        main_frame = ttk.Frame(self)  # To get styled background
1101        main_frame.grid(sticky="nsew")
1102        main_frame.rowconfigure(0, weight=1)
1103        main_frame.columnconfigure(0, weight=1)
1104
1105        version_label = ttk.Label(main_frame, text=tr("Desired version"))
1106        version_label.grid(row=0, column=0, columnspan=2, padx=20, pady=(15, 0), sticky="w")
1107
1108        def version_sort_key(s):
1109            # Trying to massage understandable versions into valid StrictVersions
1110            if s.replace(".", "").isnumeric():  # stable release
1111                s2 = s + "b999"  # make it latest beta version
1112            elif "rc" in s:
1113                s2 = s.replace("rc", "b8")
1114            else:
1115                s2 = s
1116            try:
1117                return StrictVersion(s2)
1118            except Exception:
1119                # use only numbers
1120                nums = re.findall(r"\d+", s)
1121                while len(nums) < 2:
1122                    nums.append("0")
1123                return StrictVersion(".".join(nums[:3]))
1124
1125        version_strings = list(package_metadata["releases"].keys())
1126        version_strings.sort(key=version_sort_key, reverse=True)
1127        self.version_var = ui_utils.create_string_var(
1128            selected_version, self._start_fetching_version_info
1129        )
1130        self.version_combo = ttk.Combobox(
1131            main_frame, textvariable=self.version_var, values=version_strings, exportselection=False
1132        )
1133
1134        self.version_combo.state(["!disabled", "readonly"])
1135        self.version_combo.grid(row=1, column=0, columnspan=2, pady=(0, 15), padx=20, sticky="ew")
1136
1137        self.requires_label = ttk.Label(main_frame, text="")
1138        self.requires_label.grid(row=2, column=0, columnspan=2, pady=(0, 15), padx=20, sticky="ew")
1139
1140        self.update_deps_var = tk.IntVar()
1141        self.update_deps_var.set(0)
1142        self.update_deps_cb = ttk.Checkbutton(
1143            main_frame, text=tr("Upgrade dependencies"), variable=self.update_deps_var
1144        )
1145        if support_update_deps_switch:
1146            self.update_deps_cb.grid(row=3, column=0, columnspan=2, padx=20, sticky="w")
1147
1148        self.ok_button = ttk.Button(
1149            main_frame, text=master.get_install_button_text(), command=self._ok
1150        )
1151        self.ok_button.grid(row=4, column=0, pady=15, padx=(20, 0), sticky="se")
1152        self.cancel_button = ttk.Button(main_frame, text=tr("Cancel"), command=self._cancel)
1153        self.cancel_button.grid(row=4, column=1, pady=15, padx=(5, 20), sticky="se")
1154
1155        # self.resizable(height=tk.FALSE, width=tk.FALSE)
1156        self.version_combo.focus_set()
1157
1158        self.bind("<Escape>", self._cancel, True)
1159        self.protocol("WM_DELETE_WINDOW", self._cancel)
1160
1161        if self.version_var.get().strip():
1162            self._start_fetching_version_info()
1163
1164    def _set_state(self, state):
1165        self._state = state
1166        widgets = [
1167            self.version_combo,
1168            # self.search_box, # looks funny when disabled
1169            self.ok_button,
1170            self.update_deps_cb,
1171        ]
1172
1173        if state == "idle":
1174            self.config(cursor="")
1175            for widget in widgets:
1176                if widget == self.version_combo:
1177                    widget.state(["!disabled", "readonly"])
1178                else:
1179                    widget["state"] = tk.NORMAL
1180        else:
1181            self.config(cursor=get_busy_cursor())
1182            for widget in widgets:
1183                widget["state"] = tk.DISABLED
1184
1185        if self.version_var.get().strip() == "" or not self._version_data:
1186            self.ok_button["state"] = tk.DISABLED
1187
1188    def _start_fetching_version_info(self):
1189        self._set_state("busy")
1190        _start_fetching_package_info(
1191            self._package_name, self.version_var.get(), self._show_version_info
1192        )
1193
1194    def _show_version_info(self, name, info, error_code=None):
1195        if self._closed:
1196            return
1197
1198        self._version_data = info
1199        if (
1200            not error_code
1201            and "requires_dist" in info["info"]
1202            and isinstance(info["info"]["requires_dist"], list)
1203        ):
1204            reqs = tr("Requires:") + "\n  * " + "\n  * ".join(info["info"]["requires_dist"])
1205        elif error_code:
1206            reqs = tr("Error code:") + " " + str(error_code)
1207            if "error" in info:
1208                reqs += "\n" + tr("Error:") + " " + info["error"]
1209        else:
1210            reqs = ""
1211
1212        self.requires_label.configure(text=reqs)
1213        self._set_state("idle")
1214
1215    def _ok(self, event=None):
1216        self.result = (self.version_var.get(), self._version_data, bool(self.update_deps_var.get()))
1217        self._closed = True
1218        self.destroy()
1219
1220    def _cancel(self, event=None):
1221        self.result = None
1222        self._closed = True
1223        self.destroy()
1224
1225
1226def _fetch_url_future(url, timeout=10):
1227    from urllib.request import urlopen
1228
1229    def load_url():
1230        with urlopen(url, timeout=timeout) as conn:
1231            return (conn, conn.read())
1232
1233    from concurrent.futures.thread import ThreadPoolExecutor
1234
1235    executor = ThreadPoolExecutor(max_workers=1)
1236    return executor.submit(load_url)
1237
1238
1239def _get_latest_stable_version(version_strings):
1240    from distutils.version import LooseVersion
1241
1242    versions = []
1243    for s in version_strings:
1244        if s.replace(".", "").isnumeric():  # Assuming stable versions have only dots and numbers
1245            versions.append(
1246                LooseVersion(s)
1247            )  # LooseVersion __str__ doesn't change the version string
1248
1249    if len(versions) == 0:
1250        return None
1251
1252    return str(sorted(versions)[-1])
1253
1254
1255def _ask_installation_details(master, data, selected_version, support_update_deps_switch):
1256    dlg = DetailsDialog(master, data, selected_version, support_update_deps_switch)
1257    ui_utils.show_dialog(dlg, master)
1258    return dlg.result
1259
1260
1261def _start_fetching_package_info(name, version_str, completion_handler):
1262    import urllib.error
1263    import urllib.parse
1264
1265    # Fetch info from PyPI
1266    if version_str is None:
1267        url = "https://pypi.org/pypi/{}/json".format(urllib.parse.quote(name))
1268    else:
1269        url = "https://pypi.org/pypi/{}/{}/json".format(
1270            urllib.parse.quote(name), urllib.parse.quote(version_str)
1271        )
1272
1273    url_future = _fetch_url_future(url)
1274
1275    def poll_fetch_complete():
1276        import json
1277
1278        if url_future.done():
1279            try:
1280                _, bin_data = url_future.result()
1281                raw_data = bin_data.decode("UTF-8")
1282                completion_handler(name, json.loads(raw_data))
1283            except urllib.error.HTTPError as e:
1284                completion_handler(
1285                    name, {"info": {"name": name}, "error": str(e), "releases": {}}, e.code
1286                )
1287            except Exception as e:
1288                completion_handler(
1289                    name, {"info": {"name": name}, "error": str(e), "releases": {}}, e
1290                )
1291        else:
1292            tk._default_root.after(200, poll_fetch_complete)
1293
1294    poll_fetch_complete()
1295
1296
1297def _start_fetching_search_results(query, completion_handler):
1298    import urllib.parse
1299
1300    url = "https://pypi.org/search/?q={}".format(urllib.parse.quote(query))
1301
1302    url_future = _fetch_url_future(url)
1303
1304    def poll_fetch_complete():
1305
1306        if url_future.done():
1307            try:
1308                _, bin_data = url_future.result()
1309                raw_data = bin_data.decode("UTF-8")
1310                completion_handler(query, _extract_search_results(raw_data))
1311            except Exception as e:
1312                completion_handler(query, str(e))
1313        else:
1314            tk._default_root.after(200, poll_fetch_complete)
1315
1316    poll_fetch_complete()
1317
1318
1319def _extract_search_results(html_data: str) -> List:
1320    from html.parser import HTMLParser
1321
1322    def get_class(attrs):
1323        for name, value in attrs:
1324            if name == "class":
1325                return value
1326
1327        return None
1328
1329    class_prefix = "package-snippet__"
1330
1331    class PypiSearchResultsParser(HTMLParser):
1332        def __init__(self, data):
1333            HTMLParser.__init__(self)
1334            self.results = []
1335            self.active_class = None
1336            self.feed(data)
1337
1338        def handle_starttag(self, tag, attrs):
1339            if tag == "a" and get_class(attrs) == "package-snippet":
1340                self.results.append({})
1341
1342            if tag in ("span", "p"):
1343                tag_class = get_class(attrs)
1344                if tag_class in ("package-snippet__name", "package-snippet__description"):
1345                    self.active_class = tag_class
1346                else:
1347                    self.active_class = None
1348            else:
1349                self.active_class = None
1350
1351        def handle_data(self, data):
1352            if self.active_class is not None:
1353                att_name = self.active_class[len(class_prefix) :]
1354                self.results[-1][att_name] = data
1355
1356        def handle_endtag(self, tag):
1357            self.active_class = None
1358
1359    return PypiSearchResultsParser(html_data).results
1360
1361
1362def _extract_click_text(widget, event, tag):
1363    # http://stackoverflow.com/a/33957256/261181
1364    try:
1365        index = widget.index("@%s,%s" % (event.x, event.y))
1366        tag_indices = list(widget.tag_ranges(tag))
1367        for start, end in zip(tag_indices[0::2], tag_indices[1::2]):
1368            # check if the tag matches the mouse click index
1369            if widget.compare(start, "<=", index) and widget.compare(index, "<", end):
1370                return widget.get(start, end)
1371    except Exception:
1372        logging.exception("extracting click text")
1373
1374    return None
1375
1376
1377def get_not_supported_translation():
1378    return tr("Package manager is not available for this interpreter")
1379
1380
1381def load_plugin() -> None:
1382    def get_pip_gui_class():
1383        proxy = get_runner().get_backend_proxy()
1384        if proxy is None:
1385            return None
1386        return proxy.get_pip_gui_class()
1387
1388    def open_backend_pip_gui(*args):
1389        pg_class = get_pip_gui_class()
1390        if pg_class is None:
1391            showerror(tr("Not supported"), get_not_supported_translation())
1392            return
1393
1394        if not get_runner().is_waiting_toplevel_command():
1395            showerror(
1396                tr("Not available"),
1397                tr("You need to stop your program before launching the package manager."),
1398                master=get_workbench(),
1399            )
1400            return
1401
1402        pg = pg_class(get_workbench())
1403        ui_utils.show_dialog(pg)
1404
1405    def open_backend_pip_gui_enabled():
1406        return get_pip_gui_class() is not None
1407
1408    def open_frontend_pip_gui(*args):
1409        pg = PluginsPipDialog(get_workbench())
1410        ui_utils.show_dialog(pg)
1411
1412    get_workbench().add_command(
1413        "backendpipgui",
1414        "tools",
1415        tr("Manage packages..."),
1416        open_backend_pip_gui,
1417        tester=open_backend_pip_gui_enabled,
1418        group=80,
1419    )
1420    get_workbench().add_command(
1421        "pluginspipgui", "tools", tr("Manage plug-ins..."), open_frontend_pip_gui, group=180
1422    )
1423