Coverage for vibefpmgui/window/packages.py: 80%

336 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-11-01 20:51 +0000

1"""The dependency graph: the Graph menu, a package's details and menu, updates.""" 

2 

3from __future__ import annotations 

4 

5import html 

6import json 

7import re 

8import tomllib 

9from itertools import pairwise 

10from pathlib import Path 

11 

12from qtpy.QtCore import Qt, QUrl 

13from qtpy.QtGui import QAction, QActionGroup, QDesktopServices, QKeySequence, QPalette 

14from qtpy.QtWidgets import ( 

15 QApplication, 

16 QFileDialog, 

17 QLabel, 

18 QMenu, 

19 QMessageBox, 

20 QSplitter, 

21 QTextBrowser, 

22 QVBoxLayout, 

23 QWidget, 

24) 

25 

26from .. import dep_updates 

27from .. import manifest as m 

28from ..compat import reveal_label 

29from ..graph_view import ( 

30 EDGE_STYLES, 

31 KIND_LABELS, 

32 LAYOUTS, 

33 THEME_CHOICES, 

34 DependencyGraphView, 

35) 

36from ..project_tree import reveal_in_file_manager 

37from ..widgets import run_dialog 

38 

39 

40def repo_web_url(git_url: str) -> str | None: 

41 """The web page of a GitHub/GitLab/Codeberg/Bitbucket repository.""" 

42 url = git_url.strip() 

43 match = re.match(r"^git@([^:]+):(.+?)(?:\.git)?/?$", url) 

44 if match: 

45 host, path = match.groups() 

46 else: 

47 match = re.match(r"^(?:https?|git|ssh)://(?:[^@/]+@)?([^/:]+)(?::\d+)?/(.+?)(?:\.git)?/?$", url) 

48 if not match: 

49 return None 

50 host, path = match.groups() 

51 if not any(h in host for h in ("github.", "gitlab.", "codeberg.", "bitbucket.")): 

52 return None 

53 return f"https://{host}/{path}" 

54 

55 

56def commit_web_url(git_url: str, rev: str) -> str | None: 

57 base = repo_web_url(git_url) 

58 if not base or not re.fullmatch(r"[0-9a-fA-F]{7,40}", rev or ""): 

59 return None 

60 if "bitbucket." in base: 60 ↛ 61line 60 didn't jump to line 61 because the condition on line 60 was never true

61 return f"{base}/commits/{rev}" 

62 if "gitlab." in base: 62 ↛ 63line 62 didn't jump to line 63 because the condition on line 62 was never true

63 return f"{base}/-/commit/{rev}" 

64 return f"{base}/commit/{rev}" 

65 

66 

67def compare_web_url(git_url: str, old: str, new: str) -> str | None: 

68 """A page listing the commits between two revisions (GitHub/GitLab).""" 

69 base = repo_web_url(git_url) 

70 if not base or not old or not new: 70 ↛ 71line 70 didn't jump to line 71 because the condition on line 70 was never true

71 return None 

72 if "gitlab." in base: 72 ↛ 73line 72 didn't jump to line 73 because the condition on line 72 was never true

73 return f"{base}/-/compare/{old}...{new}" 

74 if "github." in base or "codeberg." in base: 74 ↛ 76line 74 didn't jump to line 76 because the condition on line 74 was always true

75 return f"{base}/compare/{old}...{new}" 

76 return None 

77 

78 

79def toml_line(name: str, spec) -> str: 

80 """A dependency as one fpm.toml line: ``name = "1.0"`` or 

81 ``name = { git = "…", tag = "…" }``.""" 

82 def key(k): 

83 return k if re.fullmatch(r"[A-Za-z0-9_-]+", str(k)) else json.dumps(str(k)) 

84 

85 def value(v): 

86 if isinstance(v, bool): 86 ↛ 87line 86 didn't jump to line 87 because the condition on line 86 was never true

87 return "true" if v else "false" 

88 if isinstance(v, (int, float)): 88 ↛ 89line 88 didn't jump to line 89 because the condition on line 88 was never true

89 return str(v) 

90 if isinstance(v, dict): 

91 return "{ " + ", ".join(f"{key(k)} = {value(x)}" for k, x in v.items()) + " }" 

92 if isinstance(v, list): 92 ↛ 93line 92 didn't jump to line 93 because the condition on line 92 was never true

93 return "[" + ", ".join(value(x) for x in v) + "]" 

94 return json.dumps(str(v)) # (a TOML basic string) 

95 

96 return f"{key(name)} = {value(spec)}" 

97 

98 

99class PackagesMixin: 

100 """The dependency graph and its packages (a part of `vibefpmgui.main_window.MainWindow`).""" 

101 

102 def _build_graph_panel(self): 

103 """The Dependency graph panel: the graph, the selected package's details and the legend.""" 

104 self.graph_view = DependencyGraphView() 

105 self.graph_view.package_selected.connect(self.show_package) 

106 self.graph_view.package_activated.connect(self.open_package_manifest) 

107 self.graph_view.package_context_menu.connect(self._package_menu) 

108 

109 self.graph_menu = self._build_graph_menu() 

110 self.menuBar().addMenu(self.graph_menu) 

111 

112 self.legend = QLabel() 

113 self.legend.setWordWrap(True) 

114 

115 self.details = QTextBrowser() 

116 self.details.setOpenLinks(False) 

117 self.details.anchorClicked.connect(QDesktopServices.openUrl) 

118 graph_split = QSplitter(Qt.Horizontal) 

119 graph_split.addWidget(self.graph_view) 

120 graph_split.addWidget(self.details) 

121 graph_split.setStretchFactor(0, 3) 

122 graph_split.setStretchFactor(1, 1) 

123 graph_split.setSizes([700, 300]) 

124 

125 graph_tab = QWidget() 

126 gl = QVBoxLayout(graph_tab) 

127 gl.addWidget(graph_split, 1) 

128 gl.addWidget(self.legend) 

129 return graph_tab 

130 

131 def _build_graph_menu(self) -> QMenu: 

132 """Graph menu: layout, theme and edge style choices (radio groups).""" 

133 menu = QMenu("&Graph", self) 

134 self.graph_style_actions: dict[str, dict[str, QAction]] = {} 

135 for title, key, choices, setter in [ 

136 ("Layout", "layout", LAYOUTS, self._set_graph_layout), 

137 ("Theme", "theme", THEME_CHOICES, self._set_graph_theme), 

138 ("Edges", "edges", EDGE_STYLES, self._set_graph_edges), 

139 ]: 

140 sub = menu.addMenu(title) 

141 group = QActionGroup(self) 

142 group.setExclusive(True) 

143 self.graph_style_actions[key] = {} 

144 for value, label in choices.items(): 

145 action = QAction(label, self, checkable=True) 

146 action.triggered.connect(lambda _=False, v=value, f=setter: f(v)) 

147 group.addAction(action) 

148 sub.addAction(action) 

149 self.graph_style_actions[key][value] = action 

150 menu.addSeparator() 

151 self.dependencies_dev_action = QAction("Show Dependencies' Dev-Dependencies", self, checkable=True) 

152 self.dependencies_dev_action.setChecked(self.settings.value("graph_dependencies_dev", False, type=bool)) 

153 self.dependencies_dev_action.setStatusTip( 

154 "Also show what the dependencies list as dev-dependencies (fpm doesn't download those: " 

155 "only the project's own)") 

156 self.dependencies_dev_action.toggled.connect(self._set_dependencies_dev) 

157 menu.addAction(self.dependencies_dev_action) 

158 menu.addSeparator() 

159 for text, slot, shortcut in [("Fit to window", lambda: self.graph_view.fit(), "Ctrl+0"), 

160 ("Re-layout", lambda: self.graph_view.relayout(), "Ctrl+Shift+L"), 

161 ("Export image…", self.export_graph, "Ctrl+E")]: 

162 action = QAction(text, self) 

163 action.setShortcut(QKeySequence(shortcut)) 

164 action.triggered.connect(slot) 

165 menu.addAction(action) 

166 return menu 

167 

168 def _check_style_action(self, key: str, value: str): 

169 action = self.graph_style_actions[key].get(value) 

170 if action: 170 ↛ exitline 170 didn't return from function '_check_style_action' because the condition on line 170 was always true

171 action.setChecked(True) 

172 

173 def _set_graph_layout(self, value: str): 

174 self.graph_view.set_layout(value) 

175 self._check_style_action("layout", value) 

176 self.settings.setValue("graph_layout", value) 

177 

178 def _set_graph_theme(self, value: str): 

179 self.graph_view.set_theme(value) 

180 self._check_style_action("theme", value) 

181 self.settings.setValue("graph_theme", value) 

182 self._update_legend() 

183 self.build_monitor.set_package_colors(self.graph_view.package_colors()) 

184 

185 def _set_graph_edges(self, value: str): 

186 self.graph_view.set_edge_style(value) 

187 self._check_style_action("edges", value) 

188 self.settings.setValue("graph_edges", value) 

189 

190 def _set_dependencies_dev(self, shown: bool): 

191 self.settings.setValue("graph_dependencies_dev", shown) 

192 self.reload() 

193 

194 def _update_legend(self): 

195 self.legend.setText(self.graph_view.legend_html()) 

196 

197 def export_graph(self): 

198 if not self.graph: 198 ↛ 199line 198 didn't jump to line 199 because the condition on line 198 was never true

199 return 

200 default = str((self.manifest_path.parent if self.manifest_path else Path.home()) 

201 / f"{self.graph.root}-dependencies.png") 

202 filename, _ = QFileDialog.getSaveFileName( 

203 self, "Export dependency graph", default, "PNG image (*.png);;SVG image (*.svg)") 

204 if filename: 204 ↛ exitline 204 didn't return from function 'export_graph' because the condition on line 204 was always true

205 if self.graph_view.export_image(filename): 205 ↛ 208line 205 didn't jump to line 208 because the condition on line 205 was always true

206 self.statusBar().showMessage(f"Saved {filename}") 

207 else: 

208 QMessageBox.warning(self, "Export failed", f"Could not write {filename}") 

209 

210 def check_dependency_updates(self, only: str | None = None): 

211 """Commands → Check for Dependency Updates… (only runs when asked); 

212 ``only``: just that dependency (from its menu in the graph).""" 

213 if not self.graph: 213 ↛ 214line 213 didn't jump to line 214 because the condition on line 213 was never true

214 return 

215 checks = dep_updates.git_dependencies(self.graph) 

216 if only: 216 ↛ 217line 216 didn't jump to line 217 because the condition on line 216 was never true

217 checks = [c for c in checks if c.name == only] 

218 dialog = dep_updates.UpdatesDialog(checks, compare_web_url, self) 

219 dialog.update_tag_requested.connect(lambda c: self._update_dependency_tag(c, dialog)) 

220 dialog.update_deps_requested.connect(lambda: (dialog.accept(), self.run_fpm(["update"], False, True))) 

221 if checks: 

222 self._update_check_thread = dep_updates.start_check(checks, dialog, self) 

223 run_dialog(dialog) # (results still coming then go nowhere: Qt disconnects them) 

224 

225 def _update_dependency_tag(self, check, dialog): 

226 """Point one dependency at its newest tag in its fpm.toml.""" 

227 if self.busy(): 227 ↛ 228line 227 didn't jump to line 228 because the condition on line 227 was never true

228 QMessageBox.information(dialog, "Update dependency", "Wait for the running command to finish first.") 

229 return 

230 # A tag pin moves to the newest tag; a commit pin becomes that tag. 

231 if not dep_updates.set_ref(check.declared_in, check.name, check.ref_kind, check.ref, 231 ↛ 233line 231 didn't jump to line 233 because the condition on line 231 was never true

232 "tag", check.latest_tag): 

233 QMessageBox.warning( 

234 dialog, "Update dependency", 

235 f"Couldn't find {check.ref_kind} = \"{check.ref}\" for {check.name} in " 

236 f"{check.declared_in} to change it automatically; it is opened in the editor instead.") 

237 dialog.accept() 

238 self.open_file(str(check.declared_in), force_editor=True) 

239 return 

240 was = check.ref if check.ref_kind == "tag" else f"commit {check.ref[:7]}" 

241 self.statusBar().showMessage(f"{check.name}: {was} → tag {check.latest_tag} in " 

242 f"{Path(check.declared_in).name}", 6000) 

243 answer = QMessageBox.question( 

244 dialog, "Update dependency", 

245 f"{check.name} now asks for tag {check.latest_tag} (was {was}) in {check.declared_in}.\n\n" 

246 "Run Update deps now to download it?") 

247 dialog.accept() 

248 self.reload() # fpm.toml changed 

249 if answer == QMessageBox.Yes: 249 ↛ 250line 249 didn't jump to line 250 because the condition on line 249 was never true

250 self.run_fpm(["update"], False, True) 

251 

252 def _report_dependency_changes(self): 

253 """After "fpm update": which dependencies moved to another commit.""" 

254 before, self._revs_before = getattr(self, "_revs_before", None), None 

255 if before is None or not self.manifest_path: 255 ↛ 257line 255 didn't jump to line 257 because the condition on line 255 was always true

256 return 

257 after = dep_updates.read_cache_revs(self.manifest_path.parent) 

258 changed = {n: (before.get(n), rev) for n, rev in after.items() if before.get(n) != rev} 

259 if not changed: 

260 self._append("\nNo dependency changed commit.\n") 

261 return 

262 lines = ["\nDependency changes:"] 

263 for name, (old, new) in sorted(changed.items()): 

264 pkg = self.graph.packages.get(name) if self.graph else None 

265 url = compare_web_url(pkg.git, old, new) if pkg and pkg.git and old else None 

266 lines.append(f" {name}: {(old or 'new')[:7]} → {new[:7]}" + (f" {url}" if url else "")) 

267 self._append("\n".join(lines) + "\n", bold=False) 

268 self.statusBar().showMessage(f"Update deps: {len(changed)} dependenc" 

269 f"{'ies' if len(changed) != 1 else 'y'} changed", 6000) 

270 

271 def show_package(self, name: str): 

272 if not self.graph or name not in self.graph.packages: 272 ↛ 273line 272 didn't jump to line 273 because the condition on line 272 was never true

273 return 

274 pkg = self.graph.packages[name] 

275 e = html.escape 

276 rows = [("Type", KIND_LABELS[pkg.kind])] 

277 # The version: a dependency's own fpm.toml's (which the tag it's pinned to, 

278 # below, takes precedence over), or else the one asked for or fpm recorded. 

279 own = pkg.manifest_version if pkg.kind != m.ROOT else "" 

280 other = pkg.version if pkg.version != own else "" 

281 for label, value in [("Version", other), ("Manifest version", own), ("License", pkg.license), 

282 ("Author", pkg.author)]: 

283 if value: 

284 rows.append((label, e(value))) 

285 if pkg.git: 

286 web = repo_web_url(pkg.git) 

287 rows.append(("Git", f'<a href="{e(web)}">{e(pkg.git)}</a>' if web else e(pkg.git))) 

288 if pkg.git_ref: 

289 rows.append(("Requested", e(pkg.git_ref))) 

290 if pkg.git_rev: 290 ↛ 292line 290 didn't jump to line 292 because the condition on line 290 was never true

291 # The exact commit fpm checked out (from build/cache.toml). 

292 commit_url = commit_web_url(pkg.git, pkg.git_rev) 

293 short = e(pkg.git_rev[:7]) 

294 rows.append(("Commit", (f'<a href="{e(commit_url)}" title="{e(pkg.git_rev)}">{short}</a>' 

295 if commit_url else f'<span title="{e(pkg.git_rev)}">{short}</span>') 

296 + f' <span style="color:gray">({e(pkg.git_rev)})</span>')) 

297 if pkg.homepage: 297 ↛ 298line 297 didn't jump to line 298 because the condition on line 297 was never true

298 rows.append(("Homepage", f'<a href="{e(pkg.homepage)}">{e(pkg.homepage)}</a>')) 

299 if pkg.directory: 

300 url = QUrl.fromLocalFile(str(pkg.directory)).toString() 

301 rows.append(("Location", f'<a href="{e(url)}">{e(str(pkg.directory))}</a>')) 

302 deps = self.graph.children(name) 

303 users = self.graph.parents(name) 

304 if deps: 

305 rows.append(("Depends on", ", ".join( 

306 e(d.child) + (" <i>(dev)</i>" if d.dev else "") for d in deps))) 

307 if users: 

308 rows.append(("Used by", ", ".join(e(u.parent) + (" <i>(dev)</i>" if u.dev else "") for u in users))) 

309 table = "".join(f"<tr><td valign='top'><b>{k}</b>&nbsp;</td><td>{v}</td></tr>" 

310 for k, v in rows) 

311 body = f"<h3>{e(pkg.name)}</h3>" 

312 if pkg.description: 312 ↛ 313line 312 didn't jump to line 313 because the condition on line 312 was never true

313 body += f"<p>{e(pkg.description)}</p>" 

314 body += f"<table>{table}</table>" 

315 if pkg.error: 

316 body += f"<p style='color:#c92a2a'>{e(pkg.error)}</p>" 

317 conflict = m.conflicts(self.graph).get(name) 

318 if conflict: 

319 used = m.spec_key(conflict[0]) 

320 items = "".join(f"<li><b>{e(d.parent)}</b>{' (dev)' if d.dev else ''} asks for " 

321 f"{e(m.describe_spec(d.spec))}" 

322 + (" — <b>used</b>" if i == 0 else " <i>(the same)</i>" if m.spec_key(d) == used 

323 else "") + "</li>" 

324 for i, d in enumerate(conflict)) 

325 # In a red box, so it stands out (Qt's rich text draws borders on tables only). 

326 dark = self.details.palette().color(QPalette.Base).lightness() < 128 

327 red, fill = ("#ff6b6b", "#3d2224") if dark else ("#e03131", "#fff0f0") 

328 body += (f"<table width='100%' cellpadding='6' cellspacing='0' " 

329 f"style='margin-top:10px; border:1.5px solid {red}; border-collapse:collapse'><tr><td bgcolor='{fill}'>" 

330 f"<p style='color:{red}; margin-top:0'><b>⚠ Version conflict</b></p>" 

331 f"<p>{e(name)} is asked for in different ways:</p><ul>{items}</ul>" 

332 "<p style='margin-bottom:0'>fpm uses only one of them (the declaration closest " 

333 "to the project), so the others get a version they didn't ask for. Pinning the " 

334 "same version everywhere — or overriding it in the project's own fpm.toml — " 

335 "avoids surprises.</p></td></tr></table>") 

336 if pkg.manifest: 

337 body += "<p><i>Double-click the node to open its fpm.toml.</i></p>" 

338 self.details.setHtml(body) 

339 

340 def _package_menu(self, name: str, pos): 

341 """Right-click on a package in the dependency graph.""" 

342 menu = self.package_menu(name) 

343 if menu is not None: 

344 menu.exec(pos) 

345 menu.deleteLater() 

346 

347 def package_menu(self, name: str) -> QMenu | None: 

348 """The menu for a package in the dependency graph (None: not one).""" 

349 pkg = self.graph.packages.get(name) if self.graph else None 

350 if pkg is None: 

351 return None 

352 is_root = pkg.kind == m.ROOT 

353 on_disk = pkg.directory is not None and pkg.directory.is_dir() 

354 menu = QMenu(self) 

355 

356 def add(text, slot, enabled=True, tip=""): 

357 action = menu.addAction(text) 

358 action.triggered.connect(slot) 

359 action.setEnabled(bool(enabled)) 

360 if tip: 

361 action.setToolTip(tip) 

362 return action 

363 

364 add("Open fpm.toml", lambda: self.open_package_manifest(name), pkg.manifest and pkg.manifest.is_file()) 

365 add("Show in Project Files", lambda: self._show_package_files(pkg), on_disk) 

366 add(reveal_label(), lambda: reveal_in_file_manager(str(pkg.directory)), on_disk) 

367 if not is_root: 

368 has_manifest = pkg.manifest and pkg.manifest.is_file() 

369 add("Open as Project in New Window", lambda: self.open_in_new_window(pkg.manifest), has_manifest, 

370 "Open this package as a project in its own window (e.g. to work on a local " 

371 "library), keeping this project open") 

372 add("Open as Project Here", lambda: self.open_manifest(pkg.manifest), has_manifest, 

373 "Replace the project in this window with this package") 

374 links = [] 

375 if pkg.git and (url := repo_web_url(pkg.git)): 

376 links.append(("Open Repository", url)) 

377 if pkg.git and pkg.git_rev and (url := commit_web_url(pkg.git, pkg.git_rev)): 377 ↛ 378line 377 didn't jump to line 378 because the condition on line 377 was never true

378 links.append((f"Open Checked-out Commit ({pkg.git_rev[:7]})", url)) 

379 if pkg.homepage: 379 ↛ 380line 379 didn't jump to line 380 because the condition on line 379 was never true

380 links.append(("Open Homepage", pkg.homepage)) 

381 if links: 

382 menu.addSeparator() 

383 for text, url in links: 

384 add(text, lambda _=False, u=url: QDesktopServices.openUrl(QUrl(u))) 

385 if pkg.git and not is_root and pkg.kind != m.DEV_ONLY: # (fpm doesn't fetch a dependency's dev-dependency) 

386 menu.addSeparator() 

387 add("Check for Updates", lambda: self.check_dependency_updates(only=name)) 

388 add("Update This Dependency", lambda: self.run_fpm(["update", name], False, True), not self.busy(), 

389 f"fpm update {name}: fetch it again (or download it if it's missing)") 

390 if not is_root: 

391 menu.addSeparator() 

392 line = self._dependency_line(name) 

393 add("Copy Dependency Line", lambda: self._copy_dependency_line(line), line, 

394 f"Copy “{line}” to paste into another fpm.toml" if line else "") 

395 add("Why Is This Here?", lambda: self._why_is_this_here(name)) 

396 menu.setToolTipsVisible(True) 

397 return menu 

398 

399 def _show_package_files(self, pkg): 

400 if not self.project_tree.reveal_package(pkg.directory): 

401 self.statusBar().showMessage(f"{pkg.name} isn't listed in Project Files", 4000) 

402 

403 def _dependency_line(self, name: str) -> str: 

404 """How ``name`` is declared, as one fpm.toml line (e.g. ``fmin = { 

405 git = "…", tag = "1.1.3" }``), from the manifest of a package that 

406 uses it (preferring the project's own); "" if not found.""" 

407 parents = sorted((self.graph.packages[e.parent] for e in self.graph.parents(name) 

408 if e.parent in self.graph.packages), 

409 key=lambda p: (p.kind != m.ROOT, p.kind != m.PATH)) 

410 for parent in parents: 410 ↛ 421line 410 didn't jump to line 421 because the loop on line 410 didn't complete

411 try: 

412 data = m.load_toml(parent.manifest) if parent.manifest else {} 

413 except (OSError, tomllib.TOMLDecodeError): 

414 continue 

415 tables = [data.get("dependencies"), data.get("dev-dependencies")] 

416 for section in ("executable", "test", "example"): 

417 tables += [t.get("dependencies") for t in data.get(section) or [] if isinstance(t, dict)] 

418 for table in tables: 418 ↛ 410line 418 didn't jump to line 410 because the loop on line 418 didn't complete

419 if isinstance(table, dict) and name in table: 

420 return toml_line(name, table[name]) 

421 return "" 

422 

423 def _copy_dependency_line(self, line: str): 

424 QApplication.clipboard().setText(line) 

425 self.statusBar().showMessage(f"Copied: {line}", 5000) 

426 

427 def _why_is_this_here(self, name: str): 

428 """Every chain of dependencies from the project down to ``name``, 

429 highlighted in the graph and listed in the details panel.""" 

430 chains: list[list[str]] = [] 

431 

432 def walk(node, path): 

433 if len(chains) >= 50: # (plenty to see why) 433 ↛ 434line 433 didn't jump to line 434 because the condition on line 433 was never true

434 return 

435 if node == name: 

436 chains.append(path) 

437 return 

438 for edge in self.graph.children(node): 

439 if edge.child not in path: 439 ↛ 438line 439 didn't jump to line 438 because the condition on line 439 was always true

440 walk(edge.child, [*path, edge.child]) 

441 

442 walk(self.graph.root, [self.graph.root]) 

443 if not chains: 443 ↛ 444line 443 didn't jump to line 444 because the condition on line 443 was never true

444 self.statusBar().showMessage(f"{name} isn't reached from {self.graph.root}", 5000) 

445 return 

446 dev = {(e.parent, e.child) for e in self.graph.edges if e.dev} 

447 e = html.escape 

448 

449 def show(chain): 

450 out = e(chain[0]) 

451 for a, b in pairwise(chain): 

452 out += f" → <b>{e(b)}</b>" if b == name else f" → {e(b)}" 

453 if (a, b) in dev: 453 ↛ 454line 453 didn't jump to line 454 because the condition on line 453 was never true

454 out += " <i>(dev)</i>" 

455 return out 

456 

457 direct = any(len(c) == 2 for c in chains) 

458 summary = ("a direct dependency of the project" if direct and len(chains) == 1 else 

459 f"needed through {len(chains)} chain{'s' * (len(chains) != 1)}") 

460 self.details.setHtml(f"<h3>Why is {e(name)} here?</h3><p>{e(name)} is {summary}:</p><ul>" 

461 + "".join(f"<li>{show(c)}</li>" for c in chains) 

462 + "</ul><p><i>Click the background of the graph to clear the highlight.</i></p>") 

463 self.graph_view.show_chains(name, chains) 

464 self.statusBar().showMessage(f"{name}: {summary}", 6000) 

465 

466 def open_package_manifest(self, name: str): 

467 pkg = self.graph.packages.get(name) if self.graph else None 

468 if pkg and pkg.manifest: 468 ↛ 469line 468 didn't jump to line 469 because the condition on line 468 was never true

469 self.open_file(str(pkg.manifest))