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