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
« 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."""
3from __future__ import annotations
5import html
6import json
7import re
8import tomllib
9from itertools import pairwise
10from pathlib import Path
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)
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
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}"
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}"
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
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))
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)
96 return f"{key(name)} = {value(spec)}"
99class PackagesMixin:
100 """The dependency graph and its packages (a part of `vibefpmgui.main_window.MainWindow`)."""
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)
109 self.graph_menu = self._build_graph_menu()
110 self.menuBar().addMenu(self.graph_menu)
112 self.legend = QLabel()
113 self.legend.setWordWrap(True)
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])
125 graph_tab = QWidget()
126 gl = QVBoxLayout(graph_tab)
127 gl.addWidget(graph_split, 1)
128 gl.addWidget(self.legend)
129 return graph_tab
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
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)
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)
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())
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)
190 def _set_dependencies_dev(self, shown: bool):
191 self.settings.setValue("graph_dependencies_dev", shown)
192 self.reload()
194 def _update_legend(self):
195 self.legend.setText(self.graph_view.legend_html())
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}")
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)
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)
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)
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> </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)
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()
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)
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
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
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)
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 ""
423 def _copy_dependency_line(self, line: str):
424 QApplication.clipboard().setText(line)
425 self.statusBar().showMessage(f"Copied: {line}", 5000)
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]] = []
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])
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
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
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)
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))