Coverage for vibefpmgui/packages.py: 87%
212 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-01 20:51 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-01 20:51 +0000
1"""*About → Packages…*: the packages installed in the environment the app runs in.
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"""
9import json
10import os
11import shutil
12import subprocess
13import sys
14from pathlib import Path
15from typing import NamedTuple
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)
34from .background import call_in_background
35from .console import NO_WINDOW
37LIST_TIMEOUT = 60 # seconds (a first `pixi list` may have to solve the environment)
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)
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
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}"
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 = ""
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"): 79 ↛ 80line 79 didn't jump to line 80 because the condition on line 79 was never true
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
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"): 93 ↛ 94line 93 didn't jump to line 94 because the condition on line 93 was never true
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
105def python_packages() -> list[Package]:
106 """The Python packages the app can import (no pixi or conda environment)."""
107 from importlib import metadata
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: 113 ↛ 110line 113 didn't jump to line 110 because the condition on line 113 was always true
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())
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
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: 136 ↛ 137line 136 didn't jump to line 137 because the condition on line 136 was never true
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"), 147 ↛ 154line 147 didn't jump to line 154 because the loop on line 147 didn't complete
148 shutil.which("conda"), shutil.which("mamba"), shutil.which("micromamba")):
149 if exe and Path(exe).exists(): 149 ↛ 147line 149 didn't jump to line 147 because the condition on line 149 was always true
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())
158def human_size(n: int) -> str:
159 if not n: 159 ↛ 160line 159 didn't jump to line 160 because the condition on line 159 was never true
160 return ""
161 for unit in ("B", "KB", "MB", "GB"): 161 ↛ 165line 161 didn't jump to line 165 because the loop on line 161 didn't complete
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 ""
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"]
173class _SizeItem(QTableWidgetItem):
174 """Sorts by the number of bytes, not the text."""
176 def __lt__(self, other):
177 return (self.data(Qt.UserRole) or 0) < (other.data(Qt.UserRole) or 0)
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)."""
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)
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: 233 ↛ 234line 233 didn't jump to line 234 because the condition on line 233 was never true
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()
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
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 "")
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]
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))
284 def _open_page(self, row: int):
285 p = self._package(row)
286 if p is not None: 286 ↛ exitline 286 didn't return from function '_open_page' because the condition on line 286 was always true
287 QDesktopServices.openUrl(QUrl(p.page))
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()