vibefpmgui.window.packages
The dependency graph: the Graph menu, a package's details and menu, updates.
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: 61 return f"{base}/commits/{rev}" 62 if "gitlab." in base: 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: 71 return None 72 if "gitlab." in base: 73 return f"{base}/-/compare/{old}...{new}" 74 if "github." in base or "codeberg." in base: 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): 87 return "true" if v else "false" 88 if isinstance(v, (int, float)): 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): 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: 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: 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: 205 if self.graph_view.export_image(filename): 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: 214 return 215 checks = dep_updates.git_dependencies(self.graph) 216 if only: 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(): 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, 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: 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: 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: 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: 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: 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: 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)): 378 links.append((f"Open Checked-out Commit ({pkg.git_rev[:7]})", url)) 379 if pkg.homepage: 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: 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: 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) 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: 440 walk(edge.child, [*path, edge.child]) 441 442 walk(self.graph.root, [self.graph.root]) 443 if not chains: 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: 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: 469 self.open_file(str(pkg.manifest))
def
repo_web_url(git_url: str) -> str | None:
41def repo_web_url(git_url: str) -> str | None: 42 """The web page of a GitHub/GitLab/Codeberg/Bitbucket repository.""" 43 url = git_url.strip() 44 match = re.match(r"^git@([^:]+):(.+?)(?:\.git)?/?$", url) 45 if match: 46 host, path = match.groups() 47 else: 48 match = re.match(r"^(?:https?|git|ssh)://(?:[^@/]+@)?([^/:]+)(?::\d+)?/(.+?)(?:\.git)?/?$", url) 49 if not match: 50 return None 51 host, path = match.groups() 52 if not any(h in host for h in ("github.", "gitlab.", "codeberg.", "bitbucket.")): 53 return None 54 return f"https://{host}/{path}"
The web page of a GitHub/GitLab/Codeberg/Bitbucket repository.
def
commit_web_url(git_url: str, rev: str) -> str | None:
57def commit_web_url(git_url: str, rev: str) -> str | None: 58 base = repo_web_url(git_url) 59 if not base or not re.fullmatch(r"[0-9a-fA-F]{7,40}", rev or ""): 60 return None 61 if "bitbucket." in base: 62 return f"{base}/commits/{rev}" 63 if "gitlab." in base: 64 return f"{base}/-/commit/{rev}" 65 return f"{base}/commit/{rev}"
def
compare_web_url(git_url: str, old: str, new: str) -> str | None:
68def compare_web_url(git_url: str, old: str, new: str) -> str | None: 69 """A page listing the commits between two revisions (GitHub/GitLab).""" 70 base = repo_web_url(git_url) 71 if not base or not old or not new: 72 return None 73 if "gitlab." in base: 74 return f"{base}/-/compare/{old}...{new}" 75 if "github." in base or "codeberg." in base: 76 return f"{base}/compare/{old}...{new}" 77 return None
A page listing the commits between two revisions (GitHub/GitLab).
def
toml_line(name: str, spec) -> str:
80def toml_line(name: str, spec) -> str: 81 """A dependency as one fpm.toml line: ``name = "1.0"`` or 82 ``name = { git = "…", tag = "…" }``.""" 83 def key(k): 84 return k if re.fullmatch(r"[A-Za-z0-9_-]+", str(k)) else json.dumps(str(k)) 85 86 def value(v): 87 if isinstance(v, bool): 88 return "true" if v else "false" 89 if isinstance(v, (int, float)): 90 return str(v) 91 if isinstance(v, dict): 92 return "{ " + ", ".join(f"{key(k)} = {value(x)}" for k, x in v.items()) + " }" 93 if isinstance(v, list): 94 return "[" + ", ".join(value(x) for x in v) + "]" 95 return json.dumps(str(v)) # (a TOML basic string) 96 97 return f"{key(name)} = {value(spec)}"
A dependency as one fpm.toml line: name = "1.0" or
name = { git = "…", tag = "…" }.
class
PackagesMixin:
100class PackagesMixin: 101 """The dependency graph and its packages (a part of `vibefpmgui.main_window.MainWindow`).""" 102 103 def _build_graph_panel(self): 104 """The Dependency graph panel: the graph, the selected package's details and the legend.""" 105 self.graph_view = DependencyGraphView() 106 self.graph_view.package_selected.connect(self.show_package) 107 self.graph_view.package_activated.connect(self.open_package_manifest) 108 self.graph_view.package_context_menu.connect(self._package_menu) 109 110 self.graph_menu = self._build_graph_menu() 111 self.menuBar().addMenu(self.graph_menu) 112 113 self.legend = QLabel() 114 self.legend.setWordWrap(True) 115 116 self.details = QTextBrowser() 117 self.details.setOpenLinks(False) 118 self.details.anchorClicked.connect(QDesktopServices.openUrl) 119 graph_split = QSplitter(Qt.Horizontal) 120 graph_split.addWidget(self.graph_view) 121 graph_split.addWidget(self.details) 122 graph_split.setStretchFactor(0, 3) 123 graph_split.setStretchFactor(1, 1) 124 graph_split.setSizes([700, 300]) 125 126 graph_tab = QWidget() 127 gl = QVBoxLayout(graph_tab) 128 gl.addWidget(graph_split, 1) 129 gl.addWidget(self.legend) 130 return graph_tab 131 132 def _build_graph_menu(self) -> QMenu: 133 """Graph menu: layout, theme and edge style choices (radio groups).""" 134 menu = QMenu("&Graph", self) 135 self.graph_style_actions: dict[str, dict[str, QAction]] = {} 136 for title, key, choices, setter in [ 137 ("Layout", "layout", LAYOUTS, self._set_graph_layout), 138 ("Theme", "theme", THEME_CHOICES, self._set_graph_theme), 139 ("Edges", "edges", EDGE_STYLES, self._set_graph_edges), 140 ]: 141 sub = menu.addMenu(title) 142 group = QActionGroup(self) 143 group.setExclusive(True) 144 self.graph_style_actions[key] = {} 145 for value, label in choices.items(): 146 action = QAction(label, self, checkable=True) 147 action.triggered.connect(lambda _=False, v=value, f=setter: f(v)) 148 group.addAction(action) 149 sub.addAction(action) 150 self.graph_style_actions[key][value] = action 151 menu.addSeparator() 152 self.dependencies_dev_action = QAction("Show Dependencies' Dev-Dependencies", self, checkable=True) 153 self.dependencies_dev_action.setChecked(self.settings.value("graph_dependencies_dev", False, type=bool)) 154 self.dependencies_dev_action.setStatusTip( 155 "Also show what the dependencies list as dev-dependencies (fpm doesn't download those: " 156 "only the project's own)") 157 self.dependencies_dev_action.toggled.connect(self._set_dependencies_dev) 158 menu.addAction(self.dependencies_dev_action) 159 menu.addSeparator() 160 for text, slot, shortcut in [("Fit to window", lambda: self.graph_view.fit(), "Ctrl+0"), 161 ("Re-layout", lambda: self.graph_view.relayout(), "Ctrl+Shift+L"), 162 ("Export image…", self.export_graph, "Ctrl+E")]: 163 action = QAction(text, self) 164 action.setShortcut(QKeySequence(shortcut)) 165 action.triggered.connect(slot) 166 menu.addAction(action) 167 return menu 168 169 def _check_style_action(self, key: str, value: str): 170 action = self.graph_style_actions[key].get(value) 171 if action: 172 action.setChecked(True) 173 174 def _set_graph_layout(self, value: str): 175 self.graph_view.set_layout(value) 176 self._check_style_action("layout", value) 177 self.settings.setValue("graph_layout", value) 178 179 def _set_graph_theme(self, value: str): 180 self.graph_view.set_theme(value) 181 self._check_style_action("theme", value) 182 self.settings.setValue("graph_theme", value) 183 self._update_legend() 184 self.build_monitor.set_package_colors(self.graph_view.package_colors()) 185 186 def _set_graph_edges(self, value: str): 187 self.graph_view.set_edge_style(value) 188 self._check_style_action("edges", value) 189 self.settings.setValue("graph_edges", value) 190 191 def _set_dependencies_dev(self, shown: bool): 192 self.settings.setValue("graph_dependencies_dev", shown) 193 self.reload() 194 195 def _update_legend(self): 196 self.legend.setText(self.graph_view.legend_html()) 197 198 def export_graph(self): 199 if not self.graph: 200 return 201 default = str((self.manifest_path.parent if self.manifest_path else Path.home()) 202 / f"{self.graph.root}-dependencies.png") 203 filename, _ = QFileDialog.getSaveFileName( 204 self, "Export dependency graph", default, "PNG image (*.png);;SVG image (*.svg)") 205 if filename: 206 if self.graph_view.export_image(filename): 207 self.statusBar().showMessage(f"Saved {filename}") 208 else: 209 QMessageBox.warning(self, "Export failed", f"Could not write {filename}") 210 211 def check_dependency_updates(self, only: str | None = None): 212 """Commands → Check for Dependency Updates… (only runs when asked); 213 ``only``: just that dependency (from its menu in the graph).""" 214 if not self.graph: 215 return 216 checks = dep_updates.git_dependencies(self.graph) 217 if only: 218 checks = [c for c in checks if c.name == only] 219 dialog = dep_updates.UpdatesDialog(checks, compare_web_url, self) 220 dialog.update_tag_requested.connect(lambda c: self._update_dependency_tag(c, dialog)) 221 dialog.update_deps_requested.connect(lambda: (dialog.accept(), self.run_fpm(["update"], False, True))) 222 if checks: 223 self._update_check_thread = dep_updates.start_check(checks, dialog, self) 224 run_dialog(dialog) # (results still coming then go nowhere: Qt disconnects them) 225 226 def _update_dependency_tag(self, check, dialog): 227 """Point one dependency at its newest tag in its fpm.toml.""" 228 if self.busy(): 229 QMessageBox.information(dialog, "Update dependency", "Wait for the running command to finish first.") 230 return 231 # A tag pin moves to the newest tag; a commit pin becomes that tag. 232 if not dep_updates.set_ref(check.declared_in, check.name, check.ref_kind, check.ref, 233 "tag", check.latest_tag): 234 QMessageBox.warning( 235 dialog, "Update dependency", 236 f"Couldn't find {check.ref_kind} = \"{check.ref}\" for {check.name} in " 237 f"{check.declared_in} to change it automatically; it is opened in the editor instead.") 238 dialog.accept() 239 self.open_file(str(check.declared_in), force_editor=True) 240 return 241 was = check.ref if check.ref_kind == "tag" else f"commit {check.ref[:7]}" 242 self.statusBar().showMessage(f"{check.name}: {was} → tag {check.latest_tag} in " 243 f"{Path(check.declared_in).name}", 6000) 244 answer = QMessageBox.question( 245 dialog, "Update dependency", 246 f"{check.name} now asks for tag {check.latest_tag} (was {was}) in {check.declared_in}.\n\n" 247 "Run Update deps now to download it?") 248 dialog.accept() 249 self.reload() # fpm.toml changed 250 if answer == QMessageBox.Yes: 251 self.run_fpm(["update"], False, True) 252 253 def _report_dependency_changes(self): 254 """After "fpm update": which dependencies moved to another commit.""" 255 before, self._revs_before = getattr(self, "_revs_before", None), None 256 if before is None or not self.manifest_path: 257 return 258 after = dep_updates.read_cache_revs(self.manifest_path.parent) 259 changed = {n: (before.get(n), rev) for n, rev in after.items() if before.get(n) != rev} 260 if not changed: 261 self._append("\nNo dependency changed commit.\n") 262 return 263 lines = ["\nDependency changes:"] 264 for name, (old, new) in sorted(changed.items()): 265 pkg = self.graph.packages.get(name) if self.graph else None 266 url = compare_web_url(pkg.git, old, new) if pkg and pkg.git and old else None 267 lines.append(f" {name}: {(old or 'new')[:7]} → {new[:7]}" + (f" {url}" if url else "")) 268 self._append("\n".join(lines) + "\n", bold=False) 269 self.statusBar().showMessage(f"Update deps: {len(changed)} dependenc" 270 f"{'ies' if len(changed) != 1 else 'y'} changed", 6000) 271 272 def show_package(self, name: str): 273 if not self.graph or name not in self.graph.packages: 274 return 275 pkg = self.graph.packages[name] 276 e = html.escape 277 rows = [("Type", KIND_LABELS[pkg.kind])] 278 # The version: a dependency's own fpm.toml's (which the tag it's pinned to, 279 # below, takes precedence over), or else the one asked for or fpm recorded. 280 own = pkg.manifest_version if pkg.kind != m.ROOT else "" 281 other = pkg.version if pkg.version != own else "" 282 for label, value in [("Version", other), ("Manifest version", own), ("License", pkg.license), 283 ("Author", pkg.author)]: 284 if value: 285 rows.append((label, e(value))) 286 if pkg.git: 287 web = repo_web_url(pkg.git) 288 rows.append(("Git", f'<a href="{e(web)}">{e(pkg.git)}</a>' if web else e(pkg.git))) 289 if pkg.git_ref: 290 rows.append(("Requested", e(pkg.git_ref))) 291 if pkg.git_rev: 292 # The exact commit fpm checked out (from build/cache.toml). 293 commit_url = commit_web_url(pkg.git, pkg.git_rev) 294 short = e(pkg.git_rev[:7]) 295 rows.append(("Commit", (f'<a href="{e(commit_url)}" title="{e(pkg.git_rev)}">{short}</a>' 296 if commit_url else f'<span title="{e(pkg.git_rev)}">{short}</span>') 297 + f' <span style="color:gray">({e(pkg.git_rev)})</span>')) 298 if pkg.homepage: 299 rows.append(("Homepage", f'<a href="{e(pkg.homepage)}">{e(pkg.homepage)}</a>')) 300 if pkg.directory: 301 url = QUrl.fromLocalFile(str(pkg.directory)).toString() 302 rows.append(("Location", f'<a href="{e(url)}">{e(str(pkg.directory))}</a>')) 303 deps = self.graph.children(name) 304 users = self.graph.parents(name) 305 if deps: 306 rows.append(("Depends on", ", ".join( 307 e(d.child) + (" <i>(dev)</i>" if d.dev else "") for d in deps))) 308 if users: 309 rows.append(("Used by", ", ".join(e(u.parent) + (" <i>(dev)</i>" if u.dev else "") for u in users))) 310 table = "".join(f"<tr><td valign='top'><b>{k}</b> </td><td>{v}</td></tr>" 311 for k, v in rows) 312 body = f"<h3>{e(pkg.name)}</h3>" 313 if pkg.description: 314 body += f"<p>{e(pkg.description)}</p>" 315 body += f"<table>{table}</table>" 316 if pkg.error: 317 body += f"<p style='color:#c92a2a'>{e(pkg.error)}</p>" 318 conflict = m.conflicts(self.graph).get(name) 319 if conflict: 320 used = m.spec_key(conflict[0]) 321 items = "".join(f"<li><b>{e(d.parent)}</b>{' (dev)' if d.dev else ''} asks for " 322 f"{e(m.describe_spec(d.spec))}" 323 + (" — <b>used</b>" if i == 0 else " <i>(the same)</i>" if m.spec_key(d) == used 324 else "") + "</li>" 325 for i, d in enumerate(conflict)) 326 # In a red box, so it stands out (Qt's rich text draws borders on tables only). 327 dark = self.details.palette().color(QPalette.Base).lightness() < 128 328 red, fill = ("#ff6b6b", "#3d2224") if dark else ("#e03131", "#fff0f0") 329 body += (f"<table width='100%' cellpadding='6' cellspacing='0' " 330 f"style='margin-top:10px; border:1.5px solid {red}; border-collapse:collapse'><tr><td bgcolor='{fill}'>" 331 f"<p style='color:{red}; margin-top:0'><b>⚠ Version conflict</b></p>" 332 f"<p>{e(name)} is asked for in different ways:</p><ul>{items}</ul>" 333 "<p style='margin-bottom:0'>fpm uses only one of them (the declaration closest " 334 "to the project), so the others get a version they didn't ask for. Pinning the " 335 "same version everywhere — or overriding it in the project's own fpm.toml — " 336 "avoids surprises.</p></td></tr></table>") 337 if pkg.manifest: 338 body += "<p><i>Double-click the node to open its fpm.toml.</i></p>" 339 self.details.setHtml(body) 340 341 def _package_menu(self, name: str, pos): 342 """Right-click on a package in the dependency graph.""" 343 menu = self.package_menu(name) 344 if menu is not None: 345 menu.exec(pos) 346 menu.deleteLater() 347 348 def package_menu(self, name: str) -> QMenu | None: 349 """The menu for a package in the dependency graph (None: not one).""" 350 pkg = self.graph.packages.get(name) if self.graph else None 351 if pkg is None: 352 return None 353 is_root = pkg.kind == m.ROOT 354 on_disk = pkg.directory is not None and pkg.directory.is_dir() 355 menu = QMenu(self) 356 357 def add(text, slot, enabled=True, tip=""): 358 action = menu.addAction(text) 359 action.triggered.connect(slot) 360 action.setEnabled(bool(enabled)) 361 if tip: 362 action.setToolTip(tip) 363 return action 364 365 add("Open fpm.toml", lambda: self.open_package_manifest(name), pkg.manifest and pkg.manifest.is_file()) 366 add("Show in Project Files", lambda: self._show_package_files(pkg), on_disk) 367 add(reveal_label(), lambda: reveal_in_file_manager(str(pkg.directory)), on_disk) 368 if not is_root: 369 has_manifest = pkg.manifest and pkg.manifest.is_file() 370 add("Open as Project in New Window", lambda: self.open_in_new_window(pkg.manifest), has_manifest, 371 "Open this package as a project in its own window (e.g. to work on a local " 372 "library), keeping this project open") 373 add("Open as Project Here", lambda: self.open_manifest(pkg.manifest), has_manifest, 374 "Replace the project in this window with this package") 375 links = [] 376 if pkg.git and (url := repo_web_url(pkg.git)): 377 links.append(("Open Repository", url)) 378 if pkg.git and pkg.git_rev and (url := commit_web_url(pkg.git, pkg.git_rev)): 379 links.append((f"Open Checked-out Commit ({pkg.git_rev[:7]})", url)) 380 if pkg.homepage: 381 links.append(("Open Homepage", pkg.homepage)) 382 if links: 383 menu.addSeparator() 384 for text, url in links: 385 add(text, lambda _=False, u=url: QDesktopServices.openUrl(QUrl(u))) 386 if pkg.git and not is_root and pkg.kind != m.DEV_ONLY: # (fpm doesn't fetch a dependency's dev-dependency) 387 menu.addSeparator() 388 add("Check for Updates", lambda: self.check_dependency_updates(only=name)) 389 add("Update This Dependency", lambda: self.run_fpm(["update", name], False, True), not self.busy(), 390 f"fpm update {name}: fetch it again (or download it if it's missing)") 391 if not is_root: 392 menu.addSeparator() 393 line = self._dependency_line(name) 394 add("Copy Dependency Line", lambda: self._copy_dependency_line(line), line, 395 f"Copy “{line}” to paste into another fpm.toml" if line else "") 396 add("Why Is This Here?", lambda: self._why_is_this_here(name)) 397 menu.setToolTipsVisible(True) 398 return menu 399 400 def _show_package_files(self, pkg): 401 if not self.project_tree.reveal_package(pkg.directory): 402 self.statusBar().showMessage(f"{pkg.name} isn't listed in Project Files", 4000) 403 404 def _dependency_line(self, name: str) -> str: 405 """How ``name`` is declared, as one fpm.toml line (e.g. ``fmin = { 406 git = "…", tag = "1.1.3" }``), from the manifest of a package that 407 uses it (preferring the project's own); "" if not found.""" 408 parents = sorted((self.graph.packages[e.parent] for e in self.graph.parents(name) 409 if e.parent in self.graph.packages), 410 key=lambda p: (p.kind != m.ROOT, p.kind != m.PATH)) 411 for parent in parents: 412 try: 413 data = m.load_toml(parent.manifest) if parent.manifest else {} 414 except (OSError, tomllib.TOMLDecodeError): 415 continue 416 tables = [data.get("dependencies"), data.get("dev-dependencies")] 417 for section in ("executable", "test", "example"): 418 tables += [t.get("dependencies") for t in data.get(section) or [] if isinstance(t, dict)] 419 for table in tables: 420 if isinstance(table, dict) and name in table: 421 return toml_line(name, table[name]) 422 return "" 423 424 def _copy_dependency_line(self, line: str): 425 QApplication.clipboard().setText(line) 426 self.statusBar().showMessage(f"Copied: {line}", 5000) 427 428 def _why_is_this_here(self, name: str): 429 """Every chain of dependencies from the project down to ``name``, 430 highlighted in the graph and listed in the details panel.""" 431 chains: list[list[str]] = [] 432 433 def walk(node, path): 434 if len(chains) >= 50: # (plenty to see why) 435 return 436 if node == name: 437 chains.append(path) 438 return 439 for edge in self.graph.children(node): 440 if edge.child not in path: 441 walk(edge.child, [*path, edge.child]) 442 443 walk(self.graph.root, [self.graph.root]) 444 if not chains: 445 self.statusBar().showMessage(f"{name} isn't reached from {self.graph.root}", 5000) 446 return 447 dev = {(e.parent, e.child) for e in self.graph.edges if e.dev} 448 e = html.escape 449 450 def show(chain): 451 out = e(chain[0]) 452 for a, b in pairwise(chain): 453 out += f" → <b>{e(b)}</b>" if b == name else f" → {e(b)}" 454 if (a, b) in dev: 455 out += " <i>(dev)</i>" 456 return out 457 458 direct = any(len(c) == 2 for c in chains) 459 summary = ("a direct dependency of the project" if direct and len(chains) == 1 else 460 f"needed through {len(chains)} chain{'s' * (len(chains) != 1)}") 461 self.details.setHtml(f"<h3>Why is {e(name)} here?</h3><p>{e(name)} is {summary}:</p><ul>" 462 + "".join(f"<li>{show(c)}</li>" for c in chains) 463 + "</ul><p><i>Click the background of the graph to clear the highlight.</i></p>") 464 self.graph_view.show_chains(name, chains) 465 self.statusBar().showMessage(f"{name}: {summary}", 6000) 466 467 def open_package_manifest(self, name: str): 468 pkg = self.graph.packages.get(name) if self.graph else None 469 if pkg and pkg.manifest: 470 self.open_file(str(pkg.manifest))
The dependency graph and its packages (a part of vibefpmgui.main_window.MainWindow).
def
export_graph(self):
198 def export_graph(self): 199 if not self.graph: 200 return 201 default = str((self.manifest_path.parent if self.manifest_path else Path.home()) 202 / f"{self.graph.root}-dependencies.png") 203 filename, _ = QFileDialog.getSaveFileName( 204 self, "Export dependency graph", default, "PNG image (*.png);;SVG image (*.svg)") 205 if filename: 206 if self.graph_view.export_image(filename): 207 self.statusBar().showMessage(f"Saved {filename}") 208 else: 209 QMessageBox.warning(self, "Export failed", f"Could not write {filename}")
def
check_dependency_updates(self, only: str | None = None):
211 def check_dependency_updates(self, only: str | None = None): 212 """Commands → Check for Dependency Updates… (only runs when asked); 213 ``only``: just that dependency (from its menu in the graph).""" 214 if not self.graph: 215 return 216 checks = dep_updates.git_dependencies(self.graph) 217 if only: 218 checks = [c for c in checks if c.name == only] 219 dialog = dep_updates.UpdatesDialog(checks, compare_web_url, self) 220 dialog.update_tag_requested.connect(lambda c: self._update_dependency_tag(c, dialog)) 221 dialog.update_deps_requested.connect(lambda: (dialog.accept(), self.run_fpm(["update"], False, True))) 222 if checks: 223 self._update_check_thread = dep_updates.start_check(checks, dialog, self) 224 run_dialog(dialog) # (results still coming then go nowhere: Qt disconnects them)
Commands → Check for Dependency Updates… (only runs when asked);
only: just that dependency (from its menu in the graph).
def
show_package(self, name: str):
272 def show_package(self, name: str): 273 if not self.graph or name not in self.graph.packages: 274 return 275 pkg = self.graph.packages[name] 276 e = html.escape 277 rows = [("Type", KIND_LABELS[pkg.kind])] 278 # The version: a dependency's own fpm.toml's (which the tag it's pinned to, 279 # below, takes precedence over), or else the one asked for or fpm recorded. 280 own = pkg.manifest_version if pkg.kind != m.ROOT else "" 281 other = pkg.version if pkg.version != own else "" 282 for label, value in [("Version", other), ("Manifest version", own), ("License", pkg.license), 283 ("Author", pkg.author)]: 284 if value: 285 rows.append((label, e(value))) 286 if pkg.git: 287 web = repo_web_url(pkg.git) 288 rows.append(("Git", f'<a href="{e(web)}">{e(pkg.git)}</a>' if web else e(pkg.git))) 289 if pkg.git_ref: 290 rows.append(("Requested", e(pkg.git_ref))) 291 if pkg.git_rev: 292 # The exact commit fpm checked out (from build/cache.toml). 293 commit_url = commit_web_url(pkg.git, pkg.git_rev) 294 short = e(pkg.git_rev[:7]) 295 rows.append(("Commit", (f'<a href="{e(commit_url)}" title="{e(pkg.git_rev)}">{short}</a>' 296 if commit_url else f'<span title="{e(pkg.git_rev)}">{short}</span>') 297 + f' <span style="color:gray">({e(pkg.git_rev)})</span>')) 298 if pkg.homepage: 299 rows.append(("Homepage", f'<a href="{e(pkg.homepage)}">{e(pkg.homepage)}</a>')) 300 if pkg.directory: 301 url = QUrl.fromLocalFile(str(pkg.directory)).toString() 302 rows.append(("Location", f'<a href="{e(url)}">{e(str(pkg.directory))}</a>')) 303 deps = self.graph.children(name) 304 users = self.graph.parents(name) 305 if deps: 306 rows.append(("Depends on", ", ".join( 307 e(d.child) + (" <i>(dev)</i>" if d.dev else "") for d in deps))) 308 if users: 309 rows.append(("Used by", ", ".join(e(u.parent) + (" <i>(dev)</i>" if u.dev else "") for u in users))) 310 table = "".join(f"<tr><td valign='top'><b>{k}</b> </td><td>{v}</td></tr>" 311 for k, v in rows) 312 body = f"<h3>{e(pkg.name)}</h3>" 313 if pkg.description: 314 body += f"<p>{e(pkg.description)}</p>" 315 body += f"<table>{table}</table>" 316 if pkg.error: 317 body += f"<p style='color:#c92a2a'>{e(pkg.error)}</p>" 318 conflict = m.conflicts(self.graph).get(name) 319 if conflict: 320 used = m.spec_key(conflict[0]) 321 items = "".join(f"<li><b>{e(d.parent)}</b>{' (dev)' if d.dev else ''} asks for " 322 f"{e(m.describe_spec(d.spec))}" 323 + (" — <b>used</b>" if i == 0 else " <i>(the same)</i>" if m.spec_key(d) == used 324 else "") + "</li>" 325 for i, d in enumerate(conflict)) 326 # In a red box, so it stands out (Qt's rich text draws borders on tables only). 327 dark = self.details.palette().color(QPalette.Base).lightness() < 128 328 red, fill = ("#ff6b6b", "#3d2224") if dark else ("#e03131", "#fff0f0") 329 body += (f"<table width='100%' cellpadding='6' cellspacing='0' " 330 f"style='margin-top:10px; border:1.5px solid {red}; border-collapse:collapse'><tr><td bgcolor='{fill}'>" 331 f"<p style='color:{red}; margin-top:0'><b>⚠ Version conflict</b></p>" 332 f"<p>{e(name)} is asked for in different ways:</p><ul>{items}</ul>" 333 "<p style='margin-bottom:0'>fpm uses only one of them (the declaration closest " 334 "to the project), so the others get a version they didn't ask for. Pinning the " 335 "same version everywhere — or overriding it in the project's own fpm.toml — " 336 "avoids surprises.</p></td></tr></table>") 337 if pkg.manifest: 338 body += "<p><i>Double-click the node to open its fpm.toml.</i></p>" 339 self.details.setHtml(body)