vibefpmgui.packages
About → Packages…: the packages installed in the environment the app runs in.
In a pixi environment (pixi run, pixi shell) they come from pixi list
--json; in an activated conda environment from conda list --json (or
mamba / micromamba); otherwise they're the Python packages the app can see.
Shown in a sortable, filterable table.
1"""*About → Packages…*: the packages installed in the environment the app runs in. 2 3In a pixi environment (``pixi run``, ``pixi shell``) they come from ``pixi list 4--json``; in an activated conda environment from ``conda list --json`` (or 5mamba / micromamba); otherwise they're the Python packages the app can see. 6Shown in a sortable, filterable table. 7""" 8 9import json 10import os 11import shutil 12import subprocess 13import sys 14from pathlib import Path 15from typing import NamedTuple 16 17from qtpy.QtCore import Qt, QUrl 18from qtpy.QtGui import QDesktopServices 19from qtpy.QtWidgets import ( 20 QAbstractItemView, 21 QApplication, 22 QDialog, 23 QHBoxLayout, 24 QHeaderView, 25 QLabel, 26 QLineEdit, 27 QMenu, 28 QPushButton, 29 QTableWidget, 30 QTableWidgetItem, 31 QVBoxLayout, 32) 33 34from .background import call_in_background 35from .console import NO_WINDOW 36 37LIST_TIMEOUT = 60 # seconds (a first `pixi list` may have to solve the environment) 38 39 40class Package(NamedTuple): 41 name: str 42 version: str 43 build: str = "" 44 kind: str = "" # "conda" or "pypi" 45 channel: str = "" # conda channel or package index 46 license: str = "" 47 size: int = 0 # bytes (0: not known) 48 requested: bool = False # asked for in the manifest (not only needed by another package) 49 url: str = "" # where it was downloaded from ("" if not known: see page) 50 51 @property 52 def channel_name(self) -> str: 53 """The channel or index, short: "conda-forge", "pypi.org".""" 54 text = self.channel.rstrip("/") 55 if "pypi.org" in text: 56 return "pypi.org" 57 return text.rsplit("/", 1)[-1] if "://" in text else text 58 59 @property 60 def page(self) -> str: 61 """The package's web page (anaconda.org or PyPI).""" 62 if self.kind == "pypi": 63 return f"https://pypi.org/project/{self.name}/" 64 channel = self.channel.rstrip("/").rsplit("/", 1)[-1] or "conda-forge" 65 return f"https://anaconda.org/{channel}/{self.name}" 66 67 68class Listing(NamedTuple): 69 source: str # where the list is from, e.g. "pixi environment default of …/pixi.toml" 70 packages: list[Package] 71 error: str = "" 72 73 74# ------------------------------------------------------------------ parsing 75def parse_pixi(text: str) -> list[Package]: 76 """``pixi list --json`` -> packages.""" 77 out = [] 78 for item in json.loads(text): 79 if not isinstance(item, dict) or not item.get("name"): 80 continue 81 out.append(Package( 82 str(item["name"]), str(item.get("version") or ""), str(item.get("build") or ""), 83 str(item.get("kind") or ""), str(item.get("source") or item.get("index_url") or ""), 84 str(item.get("license") or ""), int(item.get("size_bytes") or 0), 85 bool(item.get("is_explicit")), str(item.get("url") or ""))) 86 return out 87 88 89def parse_conda(text: str) -> list[Package]: 90 """``conda list --json`` (also mamba and micromamba) -> packages.""" 91 out = [] 92 for item in json.loads(text): 93 if not isinstance(item, dict) or not item.get("name"): 94 continue 95 channel = str(item.get("channel") or "") 96 pypi = channel == "pypi" or item.get("platform") == "pypi" 97 base = str(item.get("base_url") or "") 98 # (No download URL: conda list doesn't say whether it was a .conda or a .tar.bz2.) 99 out.append(Package( 100 str(item["name"]), str(item.get("version") or ""), str(item.get("build_string") or ""), 101 "pypi" if pypi else "conda", base or channel)) 102 return out 103 104 105def python_packages() -> list[Package]: 106 """The Python packages the app can import (no pixi or conda environment).""" 107 from importlib import metadata 108 109 out = {} 110 for dist in metadata.distributions(): 111 meta = dist.metadata 112 name = meta.get("Name") or "" # (.get: a missing field is None, not deprecated) 113 if name and name.lower() not in out: 114 out[name.lower()] = Package(name, dist.version or "", kind="pypi", license=meta.get("License") or "", 115 url=meta.get("Home-page") or "") 116 return sorted(out.values(), key=lambda p: p.name.lower()) 117 118 119# ---------------------------------------------------------------- listing 120def _run(args: list[str]) -> str: 121 out = subprocess.run(args, capture_output=True, text=True, timeout=LIST_TIMEOUT, check=False, **NO_WINDOW) 122 if out.returncode != 0: 123 lines = [line for line in (out.stderr or out.stdout).splitlines() if line.strip()] 124 raise RuntimeError(lines[-1] if lines else f"{Path(args[0]).name} exited with code {out.returncode}") 125 return out.stdout 126 127 128def list_packages(environ=os.environ) -> Listing: 129 """The environment the app runs in, and its packages (blocking: run it 130 in the background).""" 131 manifest = environ.get("PIXI_PROJECT_MANIFEST") 132 if manifest: 133 env_name = environ.get("PIXI_ENVIRONMENT_NAME") or "default" 134 source = f"pixi environment “{env_name}” of {manifest}" 135 pixi = environ.get("PIXI_EXE") or shutil.which("pixi") 136 if not pixi: 137 return Listing(source, [], "pixi wasn't found to list the packages.") 138 try: 139 return Listing(source, parse_pixi(_run([pixi, "list", "--json", "--manifest-path", manifest, 140 "--environment", env_name]))) 141 except (OSError, subprocess.SubprocessError, RuntimeError, ValueError) as exc: 142 return Listing(source, [], f"pixi list failed: {exc}") 143 prefix = environ.get("CONDA_PREFIX") 144 if prefix: 145 name = environ.get("CONDA_DEFAULT_ENV") or Path(prefix).name 146 source = f"conda environment “{name}” ({prefix})" 147 for exe in (environ.get("CONDA_EXE"), environ.get("MAMBA_EXE"), 148 shutil.which("conda"), shutil.which("mamba"), shutil.which("micromamba")): 149 if exe and Path(exe).exists(): 150 try: 151 return Listing(source, parse_conda(_run([exe, "list", "--json", "--prefix", prefix]))) 152 except (OSError, subprocess.SubprocessError, RuntimeError, ValueError) as exc: 153 return Listing(source, [], f"{Path(exe).name} list failed: {exc}") 154 return Listing(source, [], "conda (or mamba, micromamba) wasn't found to list the packages.") 155 return Listing(f"Python packages of {sys.prefix} (not a pixi or conda environment)", python_packages()) 156 157 158def human_size(n: int) -> str: 159 if not n: 160 return "" 161 for unit in ("B", "KB", "MB", "GB"): 162 if n < 1024 or unit == "GB": 163 return f"{n:.0f} {unit}" if unit == "B" else f"{n:.1f} {unit}" 164 n /= 1024 165 return "" 166 167 168# ------------------------------------------------------------------- dialog 169MAX_COLUMN_WIDTH = 220 # pixels (but the name's; the full text is in the tooltip) 170COLUMNS = ["Name", "Version", "Build", "Type", "Channel / Index", "License", "Size", "Requested", "URL"] 171 172 173class _SizeItem(QTableWidgetItem): 174 """Sorts by the number of bytes, not the text.""" 175 176 def __lt__(self, other): 177 return (self.data(Qt.UserRole) or 0) < (other.data(Qt.UserRole) or 0) 178 179 180class PackagesDialog(QDialog): 181 """The packages of the app's environment, in a table (filled in when 182 the listing, run in the background, is done).""" 183 184 def __init__(self, parent=None, lister=None): 185 """``lister``: what makes the list (list_packages, looked up now: tests replace it).""" 186 super().__init__(parent) 187 self.setWindowTitle("Installed Packages") 188 self.packages: list[Package] = [] 189 self.source = QLabel("Listing the packages…") 190 self.source.setWordWrap(True) 191 self.source.setTextInteractionFlags(Qt.TextSelectableByMouse) 192 self.filter = QLineEdit() 193 self.filter.setPlaceholderText("Filter by name…") 194 self.filter.setClearButtonEnabled(True) 195 self.filter.textChanged.connect(self._apply_filter) 196 self.count = QLabel() 197 top = QHBoxLayout() 198 top.addWidget(self.filter, 1) 199 top.addWidget(self.count) 200 self.table = QTableWidget(0, len(COLUMNS)) 201 self.table.setHorizontalHeaderLabels(COLUMNS) 202 self.table.setEditTriggers(QAbstractItemView.NoEditTriggers) 203 self.table.setSelectionBehavior(QAbstractItemView.SelectRows) 204 self.table.setAlternatingRowColors(True) 205 self.table.verticalHeader().hide() 206 self.table.setSortingEnabled(True) 207 self.table.horizontalHeader().setStretchLastSection(True) 208 self.table.cellDoubleClicked.connect(lambda row, _c: self._open_page(row)) 209 self.table.setContextMenuPolicy(Qt.CustomContextMenu) 210 self.table.customContextMenuRequested.connect(self._context_menu) 211 self.table.setToolTip("Double-click a package for its page (anaconda.org or PyPI)") 212 copy = QPushButton("Copy List") 213 copy.setToolTip("Copy the packages shown, one per line (name, version, build, type): for bug reports") 214 copy.clicked.connect(self.copy_list) 215 close = QPushButton("Close") 216 close.setDefault(True) 217 close.clicked.connect(self.accept) 218 buttons = QHBoxLayout() 219 buttons.addWidget(copy) 220 buttons.addStretch(1) 221 buttons.addWidget(close) 222 layout = QVBoxLayout(self) 223 layout.addWidget(self.source) 224 layout.addLayout(top) 225 layout.addWidget(self.table, 1) 226 layout.addLayout(buttons) 227 self.resize(980, 560) 228 call_in_background(lister or list_packages, self.show_listing, self) 229 230 def show_listing(self, listing: Listing): 231 self.packages = listing.packages 232 text = f"<b>{len(listing.packages)} packages</b> in the {listing.source}" 233 if listing.error: 234 text = f"{listing.source}<br><span style='color:#e03131'>{listing.error}</span>" 235 self.source.setText(text) 236 self.table.setSortingEnabled(False) # (while filling: rows would move under us) 237 self.table.setRowCount(len(listing.packages)) 238 for row, p in enumerate(listing.packages): 239 values = [p.name, p.version, p.build, p.kind, p.channel_name, p.license, human_size(p.size), 240 "✓" if p.requested else "", p.url or p.page] 241 for column, value in enumerate(values): 242 item = _SizeItem(value) if column == 6 else QTableWidgetItem(value) 243 if column == 6: 244 item.setData(Qt.UserRole, p.size) 245 item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter) 246 if column == 7: 247 item.setTextAlignment(Qt.AlignCenter) 248 item.setToolTip("Asked for in the manifest" if p.requested else "Needed by another package") 249 if column in (4, 5, 8) and value: 250 item.setToolTip(p.channel if column == 4 else value) # (the full text: the column may be narrow) 251 item.setData(Qt.UserRole + 1, row) # (which package: rows move when sorted) 252 self.table.setItem(row, column, item) 253 self.table.setSortingEnabled(True) 254 self.table.sortByColumn(0, Qt.AscendingOrder) 255 header = self.table.horizontalHeader() 256 header.resizeSections(QHeaderView.ResizeToContents) 257 for column in range(1, len(COLUMNS) - 1): # (a long license mustn't push the rest out of view) 258 header.resizeSection(column, min(header.sectionSize(column), MAX_COLUMN_WIDTH)) 259 self._apply_filter() 260 261 def _package(self, row: int) -> Package | None: 262 item = self.table.item(row, 0) 263 index = item.data(Qt.UserRole + 1) if item is not None else None 264 return self.packages[index] if index is not None and 0 <= index < len(self.packages) else None 265 266 def _apply_filter(self): 267 needle = self.filter.text().strip().lower() 268 shown = 0 269 for row in range(self.table.rowCount()): 270 p = self._package(row) 271 hide = bool(needle) and (p is None or needle not in p.name.lower()) 272 self.table.setRowHidden(row, hide) 273 shown += not hide 274 self.count.setText(f"{shown} of {len(self.packages)}" if needle else "") 275 276 def visible_packages(self) -> list[Package]: 277 return [p for row in range(self.table.rowCount()) 278 if not self.table.isRowHidden(row) and (p := self._package(row)) is not None] 279 280 def copy_list(self): 281 lines = [f"{p.name} {p.version} {p.build} {p.kind}".rstrip() for p in self.visible_packages()] 282 QApplication.clipboard().setText("\n".join(lines)) 283 284 def _open_page(self, row: int): 285 p = self._package(row) 286 if p is not None: 287 QDesktopServices.openUrl(QUrl(p.page)) 288 289 def _context_menu(self, pos): 290 row = self.table.rowAt(pos.y()) 291 p = self._package(row) 292 if p is None: 293 return 294 menu = QMenu(self) 295 menu.addAction("Open Package Page", lambda: self._open_page(row)) 296 menu.addAction("Copy Name and Version", lambda: QApplication.clipboard().setText(f"{p.name} {p.version}")) 297 if p.url: 298 menu.addAction("Copy URL", lambda: QApplication.clipboard().setText(p.url)) 299 menu.exec(self.table.viewport().mapToGlobal(pos)) 300 menu.deleteLater()
41class Package(NamedTuple): 42 name: str 43 version: str 44 build: str = "" 45 kind: str = "" # "conda" or "pypi" 46 channel: str = "" # conda channel or package index 47 license: str = "" 48 size: int = 0 # bytes (0: not known) 49 requested: bool = False # asked for in the manifest (not only needed by another package) 50 url: str = "" # where it was downloaded from ("" if not known: see page) 51 52 @property 53 def channel_name(self) -> str: 54 """The channel or index, short: "conda-forge", "pypi.org".""" 55 text = self.channel.rstrip("/") 56 if "pypi.org" in text: 57 return "pypi.org" 58 return text.rsplit("/", 1)[-1] if "://" in text else text 59 60 @property 61 def page(self) -> str: 62 """The package's web page (anaconda.org or PyPI).""" 63 if self.kind == "pypi": 64 return f"https://pypi.org/project/{self.name}/" 65 channel = self.channel.rstrip("/").rsplit("/", 1)[-1] or "conda-forge" 66 return f"https://anaconda.org/{channel}/{self.name}"
Package(name, version, build, kind, channel, license, size, requested, url)
Create new instance of Package(name, version, build, kind, channel, license, size, requested, url)
52 @property 53 def channel_name(self) -> str: 54 """The channel or index, short: "conda-forge", "pypi.org".""" 55 text = self.channel.rstrip("/") 56 if "pypi.org" in text: 57 return "pypi.org" 58 return text.rsplit("/", 1)[-1] if "://" in text else text
The channel or index, short: "conda-forge", "pypi.org".
60 @property 61 def page(self) -> str: 62 """The package's web page (anaconda.org or PyPI).""" 63 if self.kind == "pypi": 64 return f"https://pypi.org/project/{self.name}/" 65 channel = self.channel.rstrip("/").rsplit("/", 1)[-1] or "conda-forge" 66 return f"https://anaconda.org/{channel}/{self.name}"
The package's web page (anaconda.org or PyPI).
69class Listing(NamedTuple): 70 source: str # where the list is from, e.g. "pixi environment default of …/pixi.toml" 71 packages: list[Package] 72 error: str = ""
Listing(source, packages, error)
Create new instance of Listing(source, packages, error)
76def parse_pixi(text: str) -> list[Package]: 77 """``pixi list --json`` -> packages.""" 78 out = [] 79 for item in json.loads(text): 80 if not isinstance(item, dict) or not item.get("name"): 81 continue 82 out.append(Package( 83 str(item["name"]), str(item.get("version") or ""), str(item.get("build") or ""), 84 str(item.get("kind") or ""), str(item.get("source") or item.get("index_url") or ""), 85 str(item.get("license") or ""), int(item.get("size_bytes") or 0), 86 bool(item.get("is_explicit")), str(item.get("url") or ""))) 87 return out
pixi list --json -> packages.
90def parse_conda(text: str) -> list[Package]: 91 """``conda list --json`` (also mamba and micromamba) -> packages.""" 92 out = [] 93 for item in json.loads(text): 94 if not isinstance(item, dict) or not item.get("name"): 95 continue 96 channel = str(item.get("channel") or "") 97 pypi = channel == "pypi" or item.get("platform") == "pypi" 98 base = str(item.get("base_url") or "") 99 # (No download URL: conda list doesn't say whether it was a .conda or a .tar.bz2.) 100 out.append(Package( 101 str(item["name"]), str(item.get("version") or ""), str(item.get("build_string") or ""), 102 "pypi" if pypi else "conda", base or channel)) 103 return out
conda list --json (also mamba and micromamba) -> packages.
106def python_packages() -> list[Package]: 107 """The Python packages the app can import (no pixi or conda environment).""" 108 from importlib import metadata 109 110 out = {} 111 for dist in metadata.distributions(): 112 meta = dist.metadata 113 name = meta.get("Name") or "" # (.get: a missing field is None, not deprecated) 114 if name and name.lower() not in out: 115 out[name.lower()] = Package(name, dist.version or "", kind="pypi", license=meta.get("License") or "", 116 url=meta.get("Home-page") or "") 117 return sorted(out.values(), key=lambda p: p.name.lower())
The Python packages the app can import (no pixi or conda environment).
129def list_packages(environ=os.environ) -> Listing: 130 """The environment the app runs in, and its packages (blocking: run it 131 in the background).""" 132 manifest = environ.get("PIXI_PROJECT_MANIFEST") 133 if manifest: 134 env_name = environ.get("PIXI_ENVIRONMENT_NAME") or "default" 135 source = f"pixi environment “{env_name}” of {manifest}" 136 pixi = environ.get("PIXI_EXE") or shutil.which("pixi") 137 if not pixi: 138 return Listing(source, [], "pixi wasn't found to list the packages.") 139 try: 140 return Listing(source, parse_pixi(_run([pixi, "list", "--json", "--manifest-path", manifest, 141 "--environment", env_name]))) 142 except (OSError, subprocess.SubprocessError, RuntimeError, ValueError) as exc: 143 return Listing(source, [], f"pixi list failed: {exc}") 144 prefix = environ.get("CONDA_PREFIX") 145 if prefix: 146 name = environ.get("CONDA_DEFAULT_ENV") or Path(prefix).name 147 source = f"conda environment “{name}” ({prefix})" 148 for exe in (environ.get("CONDA_EXE"), environ.get("MAMBA_EXE"), 149 shutil.which("conda"), shutil.which("mamba"), shutil.which("micromamba")): 150 if exe and Path(exe).exists(): 151 try: 152 return Listing(source, parse_conda(_run([exe, "list", "--json", "--prefix", prefix]))) 153 except (OSError, subprocess.SubprocessError, RuntimeError, ValueError) as exc: 154 return Listing(source, [], f"{Path(exe).name} list failed: {exc}") 155 return Listing(source, [], "conda (or mamba, micromamba) wasn't found to list the packages.") 156 return Listing(f"Python packages of {sys.prefix} (not a pixi or conda environment)", python_packages())
The environment the app runs in, and its packages (blocking: run it in the background).
181class PackagesDialog(QDialog): 182 """The packages of the app's environment, in a table (filled in when 183 the listing, run in the background, is done).""" 184 185 def __init__(self, parent=None, lister=None): 186 """``lister``: what makes the list (list_packages, looked up now: tests replace it).""" 187 super().__init__(parent) 188 self.setWindowTitle("Installed Packages") 189 self.packages: list[Package] = [] 190 self.source = QLabel("Listing the packages…") 191 self.source.setWordWrap(True) 192 self.source.setTextInteractionFlags(Qt.TextSelectableByMouse) 193 self.filter = QLineEdit() 194 self.filter.setPlaceholderText("Filter by name…") 195 self.filter.setClearButtonEnabled(True) 196 self.filter.textChanged.connect(self._apply_filter) 197 self.count = QLabel() 198 top = QHBoxLayout() 199 top.addWidget(self.filter, 1) 200 top.addWidget(self.count) 201 self.table = QTableWidget(0, len(COLUMNS)) 202 self.table.setHorizontalHeaderLabels(COLUMNS) 203 self.table.setEditTriggers(QAbstractItemView.NoEditTriggers) 204 self.table.setSelectionBehavior(QAbstractItemView.SelectRows) 205 self.table.setAlternatingRowColors(True) 206 self.table.verticalHeader().hide() 207 self.table.setSortingEnabled(True) 208 self.table.horizontalHeader().setStretchLastSection(True) 209 self.table.cellDoubleClicked.connect(lambda row, _c: self._open_page(row)) 210 self.table.setContextMenuPolicy(Qt.CustomContextMenu) 211 self.table.customContextMenuRequested.connect(self._context_menu) 212 self.table.setToolTip("Double-click a package for its page (anaconda.org or PyPI)") 213 copy = QPushButton("Copy List") 214 copy.setToolTip("Copy the packages shown, one per line (name, version, build, type): for bug reports") 215 copy.clicked.connect(self.copy_list) 216 close = QPushButton("Close") 217 close.setDefault(True) 218 close.clicked.connect(self.accept) 219 buttons = QHBoxLayout() 220 buttons.addWidget(copy) 221 buttons.addStretch(1) 222 buttons.addWidget(close) 223 layout = QVBoxLayout(self) 224 layout.addWidget(self.source) 225 layout.addLayout(top) 226 layout.addWidget(self.table, 1) 227 layout.addLayout(buttons) 228 self.resize(980, 560) 229 call_in_background(lister or list_packages, self.show_listing, self) 230 231 def show_listing(self, listing: Listing): 232 self.packages = listing.packages 233 text = f"<b>{len(listing.packages)} packages</b> in the {listing.source}" 234 if listing.error: 235 text = f"{listing.source}<br><span style='color:#e03131'>{listing.error}</span>" 236 self.source.setText(text) 237 self.table.setSortingEnabled(False) # (while filling: rows would move under us) 238 self.table.setRowCount(len(listing.packages)) 239 for row, p in enumerate(listing.packages): 240 values = [p.name, p.version, p.build, p.kind, p.channel_name, p.license, human_size(p.size), 241 "✓" if p.requested else "", p.url or p.page] 242 for column, value in enumerate(values): 243 item = _SizeItem(value) if column == 6 else QTableWidgetItem(value) 244 if column == 6: 245 item.setData(Qt.UserRole, p.size) 246 item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter) 247 if column == 7: 248 item.setTextAlignment(Qt.AlignCenter) 249 item.setToolTip("Asked for in the manifest" if p.requested else "Needed by another package") 250 if column in (4, 5, 8) and value: 251 item.setToolTip(p.channel if column == 4 else value) # (the full text: the column may be narrow) 252 item.setData(Qt.UserRole + 1, row) # (which package: rows move when sorted) 253 self.table.setItem(row, column, item) 254 self.table.setSortingEnabled(True) 255 self.table.sortByColumn(0, Qt.AscendingOrder) 256 header = self.table.horizontalHeader() 257 header.resizeSections(QHeaderView.ResizeToContents) 258 for column in range(1, len(COLUMNS) - 1): # (a long license mustn't push the rest out of view) 259 header.resizeSection(column, min(header.sectionSize(column), MAX_COLUMN_WIDTH)) 260 self._apply_filter() 261 262 def _package(self, row: int) -> Package | None: 263 item = self.table.item(row, 0) 264 index = item.data(Qt.UserRole + 1) if item is not None else None 265 return self.packages[index] if index is not None and 0 <= index < len(self.packages) else None 266 267 def _apply_filter(self): 268 needle = self.filter.text().strip().lower() 269 shown = 0 270 for row in range(self.table.rowCount()): 271 p = self._package(row) 272 hide = bool(needle) and (p is None or needle not in p.name.lower()) 273 self.table.setRowHidden(row, hide) 274 shown += not hide 275 self.count.setText(f"{shown} of {len(self.packages)}" if needle else "") 276 277 def visible_packages(self) -> list[Package]: 278 return [p for row in range(self.table.rowCount()) 279 if not self.table.isRowHidden(row) and (p := self._package(row)) is not None] 280 281 def copy_list(self): 282 lines = [f"{p.name} {p.version} {p.build} {p.kind}".rstrip() for p in self.visible_packages()] 283 QApplication.clipboard().setText("\n".join(lines)) 284 285 def _open_page(self, row: int): 286 p = self._package(row) 287 if p is not None: 288 QDesktopServices.openUrl(QUrl(p.page)) 289 290 def _context_menu(self, pos): 291 row = self.table.rowAt(pos.y()) 292 p = self._package(row) 293 if p is None: 294 return 295 menu = QMenu(self) 296 menu.addAction("Open Package Page", lambda: self._open_page(row)) 297 menu.addAction("Copy Name and Version", lambda: QApplication.clipboard().setText(f"{p.name} {p.version}")) 298 if p.url: 299 menu.addAction("Copy URL", lambda: QApplication.clipboard().setText(p.url)) 300 menu.exec(self.table.viewport().mapToGlobal(pos)) 301 menu.deleteLater()
The packages of the app's environment, in a table (filled in when the listing, run in the background, is done).
185 def __init__(self, parent=None, lister=None): 186 """``lister``: what makes the list (list_packages, looked up now: tests replace it).""" 187 super().__init__(parent) 188 self.setWindowTitle("Installed Packages") 189 self.packages: list[Package] = [] 190 self.source = QLabel("Listing the packages…") 191 self.source.setWordWrap(True) 192 self.source.setTextInteractionFlags(Qt.TextSelectableByMouse) 193 self.filter = QLineEdit() 194 self.filter.setPlaceholderText("Filter by name…") 195 self.filter.setClearButtonEnabled(True) 196 self.filter.textChanged.connect(self._apply_filter) 197 self.count = QLabel() 198 top = QHBoxLayout() 199 top.addWidget(self.filter, 1) 200 top.addWidget(self.count) 201 self.table = QTableWidget(0, len(COLUMNS)) 202 self.table.setHorizontalHeaderLabels(COLUMNS) 203 self.table.setEditTriggers(QAbstractItemView.NoEditTriggers) 204 self.table.setSelectionBehavior(QAbstractItemView.SelectRows) 205 self.table.setAlternatingRowColors(True) 206 self.table.verticalHeader().hide() 207 self.table.setSortingEnabled(True) 208 self.table.horizontalHeader().setStretchLastSection(True) 209 self.table.cellDoubleClicked.connect(lambda row, _c: self._open_page(row)) 210 self.table.setContextMenuPolicy(Qt.CustomContextMenu) 211 self.table.customContextMenuRequested.connect(self._context_menu) 212 self.table.setToolTip("Double-click a package for its page (anaconda.org or PyPI)") 213 copy = QPushButton("Copy List") 214 copy.setToolTip("Copy the packages shown, one per line (name, version, build, type): for bug reports") 215 copy.clicked.connect(self.copy_list) 216 close = QPushButton("Close") 217 close.setDefault(True) 218 close.clicked.connect(self.accept) 219 buttons = QHBoxLayout() 220 buttons.addWidget(copy) 221 buttons.addStretch(1) 222 buttons.addWidget(close) 223 layout = QVBoxLayout(self) 224 layout.addWidget(self.source) 225 layout.addLayout(top) 226 layout.addWidget(self.table, 1) 227 layout.addLayout(buttons) 228 self.resize(980, 560) 229 call_in_background(lister or list_packages, self.show_listing, self)
lister: what makes the list (list_packages, looked up now: tests replace it).
231 def show_listing(self, listing: Listing): 232 self.packages = listing.packages 233 text = f"<b>{len(listing.packages)} packages</b> in the {listing.source}" 234 if listing.error: 235 text = f"{listing.source}<br><span style='color:#e03131'>{listing.error}</span>" 236 self.source.setText(text) 237 self.table.setSortingEnabled(False) # (while filling: rows would move under us) 238 self.table.setRowCount(len(listing.packages)) 239 for row, p in enumerate(listing.packages): 240 values = [p.name, p.version, p.build, p.kind, p.channel_name, p.license, human_size(p.size), 241 "✓" if p.requested else "", p.url or p.page] 242 for column, value in enumerate(values): 243 item = _SizeItem(value) if column == 6 else QTableWidgetItem(value) 244 if column == 6: 245 item.setData(Qt.UserRole, p.size) 246 item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter) 247 if column == 7: 248 item.setTextAlignment(Qt.AlignCenter) 249 item.setToolTip("Asked for in the manifest" if p.requested else "Needed by another package") 250 if column in (4, 5, 8) and value: 251 item.setToolTip(p.channel if column == 4 else value) # (the full text: the column may be narrow) 252 item.setData(Qt.UserRole + 1, row) # (which package: rows move when sorted) 253 self.table.setItem(row, column, item) 254 self.table.setSortingEnabled(True) 255 self.table.sortByColumn(0, Qt.AscendingOrder) 256 header = self.table.horizontalHeader() 257 header.resizeSections(QHeaderView.ResizeToContents) 258 for column in range(1, len(COLUMNS) - 1): # (a long license mustn't push the rest out of view) 259 header.resizeSection(column, min(header.sectionSize(column), MAX_COLUMN_WIDTH)) 260 self._apply_filter()