Coverage for vibefpmgui/packages.py: 87%

212 statements  

« 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. 

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

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

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: 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()) 

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: 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()) 

156 

157 

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

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: 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() 

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: 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)) 

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()