Coverage for vibefpmgui/dep_updates.py: 90%
361 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"""Check git dependencies for newer versions (only when the user asks).
3For each git dependency the remote is asked for its tags and branches with
4``git ls-remote`` (nothing is downloaded), and compared with what the project
5requests (tag / branch / rev / nothing) and the commit fpm checked out
6(``build/cache.toml``). A dependency pinned to an older tag can be moved to
7the newest tag: only that tag value in the declaring ``fpm.toml`` is changed.
8"""
10from __future__ import annotations
12import os
13import re
14import subprocess
15import tomllib
16from concurrent.futures import ThreadPoolExecutor, as_completed
17from dataclasses import dataclass
18from pathlib import Path
19from typing import ClassVar
21from qtpy.QtCore import QObject, Qt, QThread, QUrl, Signal, Slot
22from qtpy.QtGui import QColor, QDesktopServices
23from qtpy.QtWidgets import (
24 QAbstractItemView,
25 QDialog,
26 QHBoxLayout,
27 QHeaderView,
28 QLabel,
29 QPushButton,
30 QTableWidget,
31 QTableWidgetItem,
32 QVBoxLayout,
33 QWidget,
34)
36from . import manifest as m
37from .console import NO_WINDOW
39PRERELEASE_RE = re.compile(r"(alpha|beta|rc|dev|pre|preview|snapshot|nightly)", re.IGNORECASE)
40LS_REMOTE_TIMEOUT = 30 # seconds per repository
42# Status kinds (also used for colors)
43UP_TO_DATE, UPDATE, NEW_COMMITS, INFO, ERROR = "up to date", "update", "new commits", "info", "error"
44TAG_AVAILABLE = "tag available" # pinned to a commit; a tag could be used instead
45STATUS_COLORS = {UP_TO_DATE: "#2f9e44", UPDATE: "#e8590c", NEW_COMMITS: "#1c7ed6",
46 TAG_AVAILABLE: "#7048e8", INFO: "#868e96", ERROR: "#e03131"}
49@dataclass
50class DepCheck:
51 name: str
52 git: str
53 ref_kind: str = "" # "tag", "branch", "rev" or "" (default branch)
54 ref: str = ""
55 current: str = "" # commit fpm checked out (build/cache.toml)
56 declared_in: Path | None = None
57 editable: bool = False # the declaring fpm.toml is the user's (root / path dependency)
58 latest_tag: str = ""
59 latest_tag_commit: str = ""
60 branch_commit: str = "" # newest commit of the requested (or default) branch
61 status: str = ""
62 message: str = ""
64 @property
65 def requested(self) -> str:
66 if self.ref_kind == "rev": 66 ↛ 67line 66 didn't jump to line 67 because the condition on line 66 was never true
67 return f"rev {self.ref[:7]}"
68 return f"{self.ref_kind} {self.ref}" if self.ref_kind else "default branch"
71# ------------------------------------------------------------------ collect
72def git_dependencies(graph: m.DependencyGraph) -> list[DepCheck]:
73 """Every git dependency, with how it is requested and where."""
74 checks = []
75 for pkg in graph.packages.values():
76 if not pkg.git or pkg.kind in (m.ROOT, m.DEV_ONLY): # (a dependency's dev-dependency isn't fetched)
77 continue
78 check = DepCheck(pkg.name, pkg.git, current=pkg.git_rev)
79 # Prefer a declaration in a manifest the user can edit.
80 parents = [graph.packages[e.parent] for e in graph.parents(pkg.name) if e.parent in graph.packages]
81 parents.sort(key=lambda p: p.kind not in (m.ROOT, m.PATH))
82 for parent in parents: 82 ↛ 93line 82 didn't jump to line 93 because the loop on line 82 didn't complete
83 spec = _declared_spec(parent.manifest, pkg.name) if parent.manifest else None
84 if spec is not None: 84 ↛ 82line 84 didn't jump to line 82 because the condition on line 84 was always true
85 check.declared_in = parent.manifest
86 check.editable = parent.kind in (m.ROOT, m.PATH)
87 check.git = str(spec.get("git", check.git))
88 for key in ("tag", "branch", "rev"): 88 ↛ 92line 88 didn't jump to line 92 because the loop on line 88 didn't complete
89 if key in spec: 89 ↛ 88line 89 didn't jump to line 88 because the condition on line 89 was always true
90 check.ref_kind, check.ref = key, str(spec[key])
91 break
92 break
93 checks.append(check)
94 return sorted(checks, key=lambda c: (not c.editable, c.name.lower()))
97def _declared_spec(manifest: Path, name: str) -> dict | None:
98 try:
99 data = m.load_toml(manifest)
100 except (OSError, tomllib.TOMLDecodeError):
101 return None
102 for section in ("dependencies", "dev-dependencies"): 102 ↛ 106line 102 didn't jump to line 106 because the loop on line 102 didn't complete
103 spec = (data.get(section) or {}).get(name)
104 if isinstance(spec, dict): 104 ↛ 102line 104 didn't jump to line 102 because the condition on line 104 was always true
105 return spec
106 return None
109# -------------------------------------------------------------------- check
110ALLOWED_GIT_PROTOCOLS = "https:http:ssh:git:file"
113def git_environment(environ=os.environ) -> dict[str, str]:
114 """The environment for git commands that must never wait for a prompt:
115 no password questions (HTTPS), and SSH in batch mode (no host-key or
116 passphrase questions: a key in the agent still works; otherwise it fails
117 at once instead of waiting out the timeout). A GIT_SSH_COMMAND or
118 GIT_SSH of the user's own is left as it is.
120 Only real transports are allowed: a URL comes from a project's fpm.toml,
121 which may not be trustworthy, and git's "ext::" transport (and other
122 remote helpers) would run the command in the URL."""
123 env = dict(environ, GIT_TERMINAL_PROMPT="0", GIT_ALLOW_PROTOCOL=ALLOWED_GIT_PROTOCOLS)
124 if not env.get("GIT_SSH_COMMAND") and not env.get("GIT_SSH"):
125 env["GIT_SSH_COMMAND"] = "ssh -o BatchMode=yes"
126 return env
129def git_error(stderr: str) -> str:
130 """What went wrong, from git's message: its first line (the cause, e.g.
131 "Permission denied (publickey)") and its "fatal:" one, with a hint when
132 SSH couldn't ask for a key or a host key (see git_environment)."""
133 lines = [line.strip() for line in stderr.splitlines() if line.strip()]
134 if not lines:
135 return ""
136 fatal = next((line.removeprefix("fatal: ") for line in lines if line.startswith("fatal:")), "")
137 first = lines[0].removeprefix("fatal: ")
138 text = first if not fatal or fatal == first else f"{first} — {fatal}"
139 if "publickey" in stderr or "Host key verification failed" in stderr:
140 text += (" (SSH can't ask for a key or a host's fingerprint here: load the key into ssh-agent "
141 "and connect once in a terminal, or use the repository's https:// URL)")
142 return text
145def ls_remote(url: str) -> dict[str, str]:
146 """{ref: commit} for HEAD, branches and tags (annotated tags resolved
147 to their commit). Raises RuntimeError with git's message on failure."""
148 env = git_environment()
149 try:
150 # ("--": a URL from fpm.toml that starts with "-", such as
151 # "--upload-pack=<command>", is a URL to git, never an option.)
152 out = subprocess.run(["git", "ls-remote", "--", url, "HEAD", "refs/heads/*", "refs/tags/*"],
153 capture_output=True, text=True, timeout=LS_REMOTE_TIMEOUT, env=env, check=False,
154 **NO_WINDOW)
155 except FileNotFoundError:
156 raise RuntimeError("git was not found on PATH")
157 except subprocess.TimeoutExpired:
158 raise RuntimeError(f"no answer from the server within {LS_REMOTE_TIMEOUT} s")
159 if out.returncode != 0:
160 raise RuntimeError(git_error(out.stderr or "") or f"git ls-remote failed ({out.returncode})")
161 refs: dict[str, str] = {}
162 for line in out.stdout.splitlines():
163 sha, _, ref = line.partition("\t")
164 if ref.endswith("^{}"):
165 refs[ref[:-3]] = sha # the commit an annotated tag points to
166 else:
167 refs.setdefault(ref, sha)
168 return refs
171def version_key(tag: str) -> tuple:
172 """Sort key for tags like v1.2.3 / 1.2 / release-2.0.1."""
173 numbers = tuple(int(n) for n in re.findall(r"\d+", tag))
174 return (numbers, not PRERELEASE_RE.search(tag), tag)
177def newest_tag(tags: list[str], allow_prerelease: bool = False) -> str:
178 candidates = [t for t in tags if re.search(r"\d", t)
179 and (allow_prerelease or not PRERELEASE_RE.search(t))]
180 return max(candidates, key=version_key) if candidates else ""
183def check_dependency(check: DepCheck) -> DepCheck:
184 try:
185 refs = ls_remote(check.git)
186 except RuntimeError as exc:
187 check.status, check.message = ERROR, f"Could not reach the repository: {exc}"
188 return check
189 tags = {ref[len("refs/tags/"):]: sha for ref, sha in refs.items() if ref.startswith("refs/tags/")}
190 allow_pre = check.ref_kind == "tag" and bool(PRERELEASE_RE.search(check.ref))
191 check.latest_tag = newest_tag(list(tags), allow_pre)
192 check.latest_tag_commit = tags.get(check.latest_tag, "")
193 if check.ref_kind == "branch":
194 check.branch_commit = refs.get(f"refs/heads/{check.ref}", "")
195 else:
196 check.branch_commit = refs.get("HEAD", "")
197 current = check.current
199 if check.ref_kind == "tag":
200 if check.ref not in tags:
201 check.status, check.message = ERROR, f"Tag {check.ref} no longer exists in the repository"
202 elif check.latest_tag and version_key(check.latest_tag) > version_key(check.ref):
203 check.status = UPDATE
204 check.message = f"Newer tag available: {check.ref} → {check.latest_tag}"
205 else:
206 check.status, check.message = UP_TO_DATE, f"{check.ref} is the newest tag"
207 elif check.ref_kind == "rev":
208 pinned = check.ref
209 if not check.latest_tag:
210 check.status, check.message = INFO, "Pinned to a commit (the repository has no tags)"
211 elif check.latest_tag_commit and (check.latest_tag_commit.startswith(pinned)
212 or pinned.startswith(check.latest_tag_commit)):
213 check.status = TAG_AVAILABLE
214 check.message = f"Pinned to commit {pinned[:7]}, which is tag {check.latest_tag}"
215 else:
216 check.status = TAG_AVAILABLE
217 check.message = (f"Pinned to a commit; the newest tag is {check.latest_tag} "
218 "(Compare shows whether it is newer)")
219 else: # a branch, or the default branch
220 where = f"branch {check.ref}" if check.ref_kind == "branch" else "the default branch"
221 if not check.branch_commit:
222 check.status, check.message = ERROR, f"Could not find {where} in the repository"
223 elif not current:
224 check.status, check.message = INFO, "Not downloaded yet — Update deps fetches it"
225 elif check.branch_commit.startswith(current) or current.startswith(check.branch_commit):
226 check.status, check.message = UP_TO_DATE, f"Up to date with {where}"
227 else:
228 check.status = NEW_COMMITS
229 check.message = f"{where.capitalize()} has new commits — Update deps fetches them"
230 return check
233# --------------------------------------------------------------- edit tags
234def set_ref(manifest: Path, name: str, old_key: str, old: str, new_key: str, new: str) -> bool:
235 """Change ``old_key = "old"`` to ``new_key = "new"`` (e.g. a ``rev`` pin
236 to a ``tag``) for one dependency, leaving everything else in the file
237 (comments, layout) untouched. Returns False if the declaration couldn't
238 be found unambiguously."""
239 text = manifest.read_text()
240 tag_re = re.compile(r"(\b)" + re.escape(old_key) + r"""(\s*=\s*)(["'])""" + re.escape(old) + r"""\3""")
241 lines = text.split("\n")
242 target = None
243 # 1. Inline table: name = { git = "...", tag = "old" }
244 key = re.compile(r"""^\s*(?:["']?)""" + re.escape(name) + r"""(?:["']?)\s*=\s*\{""")
245 for i, line in enumerate(lines):
246 if key.match(line) and tag_re.search(line):
247 target = i
248 break
249 # 2. Table form: [dependencies.name] ... tag = "old"
250 if target is None:
251 header = re.compile(r"^\s*\[\s*(?:dev-)?dependencies\.(?:[\"']?)" + re.escape(name)
252 + r"(?:[\"']?)\s*\]")
253 for i, line in enumerate(lines):
254 if header.match(line):
255 for j in range(i + 1, len(lines)): 255 ↛ 261line 255 didn't jump to line 261 because the loop on line 255 didn't complete
256 if lines[j].lstrip().startswith("["): 256 ↛ 257line 256 didn't jump to line 257 because the condition on line 256 was never true
257 break
258 if tag_re.search(lines[j]):
259 target = j
260 break
261 break
262 if target is None:
263 return False
264 lines[target] = tag_re.sub(lambda mt: f"{mt.group(1)}{new_key}{mt.group(2)}{mt.group(3)}{new}{mt.group(3)}",
265 lines[target], count=1)
266 updated = "\n".join(lines)
267 # Make sure the result parses and says what we meant.
268 try:
269 data = tomllib.loads(updated)
270 except tomllib.TOMLDecodeError:
271 return False
272 spec = (data.get("dependencies") or {}).get(name) or (data.get("dev-dependencies") or {}).get(name)
273 if not isinstance(spec, dict) or spec.get(new_key) != new or (old_key != new_key and old_key in spec): 273 ↛ 274line 273 didn't jump to line 274 because the condition on line 273 was never true
274 return False
275 manifest.write_text(updated)
276 return True
279def read_cache_revs(project_dir: Path) -> dict[str, str]:
280 """{dependency: commit} for the downloaded git dependencies, from each
281 checkout itself (build/cache.toml's "rev" isn't updated by fpm update)."""
282 revs: dict[str, str] = {}
283 folder = project_dir / "build" / "dependencies"
284 if folder.is_dir(): 284 ↛ 289line 284 didn't jump to line 289 because the condition on line 284 was always true
285 for dep in folder.iterdir():
286 commit = m.checked_out_commit(dep)
287 if commit: 287 ↛ 285line 287 didn't jump to line 285 because the condition on line 287 was always true
288 revs[dep.name] = commit
289 return revs
292# ------------------------------------------------------------------ worker
293class CheckWorker(QObject):
294 result = Signal(int, object) # row, DepCheck
295 finished = Signal()
297 def __init__(self, checks: list[DepCheck]):
298 super().__init__()
299 self.checks = checks
301 @Slot()
302 def run(self):
303 try:
304 with ThreadPoolExecutor(max_workers=6) as pool:
305 futures = {pool.submit(check_dependency, c): i for i, c in enumerate(self.checks)}
306 for future in as_completed(futures):
307 row = futures[future]
308 try:
309 check = future.result()
310 except Exception as exc: # noqa: BLE001 - that row's error, not the end of the check
311 check = self.checks[row]
312 check.status, check.message = ERROR, f"Could not be checked: {exc}"
313 self.result.emit(row, check)
314 finally:
315 self.finished.emit() # (always: it ends the thread)
318# ------------------------------------------------------------------ dialog
319class UpdatesDialog(QDialog):
320 """Results of a dependency check, with actions per row."""
322 update_tag_requested = Signal(object) # DepCheck
323 update_deps_requested = Signal()
325 COLUMNS: ClassVar[list[str]] = ["Dependency", "Requested", "Current", "Newest", "Status", ""]
327 def __init__(self, checks: list[DepCheck], compare_url, parent=None):
328 """``compare_url(git, old, new)``: a web page comparing two commits (or None)."""
329 super().__init__(parent)
330 self.setWindowTitle("Dependency updates")
331 self.checks = checks
332 self._compare_url = compare_url
333 self.summary = QLabel()
334 self.summary.setWordWrap(True)
335 note = QLabel("<span style='color:gray'>Asks each repository for its tags and branches "
336 "(<code>git ls-remote</code>); nothing is downloaded and no code is sent.</span>")
337 note.setWordWrap(True)
338 self.table = QTableWidget(len(checks), len(self.COLUMNS))
339 self.table.setHorizontalHeaderLabels(self.COLUMNS)
340 self.table.verticalHeader().setVisible(False)
341 self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
342 self.table.setSelectionMode(QAbstractItemView.NoSelection)
343 # Compact: one line per row (long text is elided; tooltips have it all),
344 # fixed row height, no grid, alternating shading.
345 self.table.setWordWrap(False)
346 self.table.setTextElideMode(Qt.ElideRight)
347 self.table.setShowGrid(False)
348 self.table.setAlternatingRowColors(True)
349 self.row_height = self.table.fontMetrics().height() + 12
350 self.table.verticalHeader().setSectionResizeMode(QHeaderView.Fixed)
351 self.table.verticalHeader().setDefaultSectionSize(self.row_height)
352 hh = self.table.horizontalHeader()
353 for col in range(len(self.COLUMNS) - 1):
354 hh.setSectionResizeMode(col, QHeaderView.ResizeToContents)
355 hh.setSectionResizeMode(4, QHeaderView.Stretch)
356 for row, c in enumerate(checks):
357 self._set_row(row, c, checking=True)
358 close = QPushButton("Close")
359 close.clicked.connect(self.accept)
360 self.update_all = QPushButton("Update deps")
361 self.update_all.setToolTip("Run fpm update (fetches new commits of branch dependencies)")
362 self.update_all.clicked.connect(self.update_deps_requested)
363 self.update_all.hide()
364 buttons = QHBoxLayout()
365 buttons.addWidget(self.update_all)
366 buttons.addStretch(1)
367 buttons.addWidget(close)
368 layout = QVBoxLayout(self)
369 layout.addWidget(self.summary)
370 layout.addWidget(note)
371 layout.addWidget(self.table, 1)
372 layout.addLayout(buttons)
373 header = self.table.horizontalHeader().sizeHint().height()
374 self.resize(1120, 150 + header + self.row_height * max(3, min(len(checks), 12)))
375 self._done = 0
376 self._update_summary()
378 def _set_row(self, row: int, c: DepCheck, checking: bool = False):
379 def cell(col, text, tip="", color=None):
380 item = QTableWidgetItem(text)
381 if tip:
382 item.setToolTip(tip)
383 if color:
384 item.setForeground(QColor(color))
385 self.table.setItem(row, col, item)
387 where = "" if c.declared_in is None else f"declared in {c.declared_in}"
388 cell(0, c.name + ("" if c.editable else " (indirect)"), f"{c.git}\n{where}")
389 cell(1, c.requested, f"{c.ref_kind} {c.ref}" if c.ref_kind else "the repository's default branch")
390 cell(2, c.current[:7] if c.current else "—", c.current)
391 if checking:
392 cell(3, "…")
393 cell(4, "checking…", color="#868e96")
394 return
395 newest = c.latest_tag or "—"
396 if c.ref_kind in ("branch", "") and c.branch_commit: 396 ↛ 397line 396 didn't jump to line 397 because the condition on line 396 was never true
397 newest = f"{c.branch_commit[:7]}" + (f" (tag {c.latest_tag})" if c.latest_tag else "")
398 cell(3, newest, c.latest_tag_commit or c.branch_commit)
399 cell(4, c.message, c.message, STATUS_COLORS.get(c.status))
400 actions = QWidget()
401 row_layout = QHBoxLayout(actions)
402 row_layout.setContentsMargins(4, 0, 4, 0)
403 row_layout.setSpacing(4)
404 new_commit = c.latest_tag_commit if c.status in (UPDATE, TAG_AVAILABLE) else c.branch_commit
405 compare = self._compare_url(c.git, c.current, new_commit) if c.current and new_commit else None
406 if c.status == UPDATE and c.editable: 406 ↛ 411line 406 didn't jump to line 411 because the condition on line 406 was always true
407 button = self._small_button(f"Update to {c.latest_tag}")
408 button.setToolTip(f"Change tag = \"{c.ref}\" to \"{c.latest_tag}\" in {c.declared_in}")
409 button.clicked.connect(lambda _=False, ch=c: self.update_tag_requested.emit(ch))
410 row_layout.addWidget(button)
411 elif c.status == TAG_AVAILABLE and c.editable:
412 same = c.latest_tag_commit and (c.latest_tag_commit.startswith(c.ref) or c.ref.startswith(c.latest_tag_commit))
413 button = self._small_button(f"{'Use' if same else 'Switch to'} tag {c.latest_tag}")
414 button.setToolTip(f"Replace rev = \"{c.ref[:12]}…\" with tag = \"{c.latest_tag}\" in {c.declared_in}"
415 + ("" if same else "\n(Compare first: the tag may be older or newer than the pinned commit)"))
416 button.clicked.connect(lambda _=False, ch=c: self.update_tag_requested.emit(ch))
417 row_layout.addWidget(button)
418 elif c.status in (UPDATE, TAG_AVAILABLE):
419 note = QLabel("<span style='color:gray'>declared by another dependency</span>")
420 row_layout.addWidget(note)
421 if compare and c.status in (UPDATE, NEW_COMMITS) or ( 421 ↛ 423line 421 didn't jump to line 423 because the condition on line 421 was never true
422 compare and c.status == TAG_AVAILABLE and not c.latest_tag_commit.startswith(c.current[:7])):
423 link = self._small_button("Compare")
424 link.setToolTip(compare)
425 link.clicked.connect(lambda _=False, u=compare: QDesktopServices.openUrl(QUrl(u)))
426 row_layout.addWidget(link)
427 row_layout.addStretch(1) # buttons keep their natural width
428 self.table.setCellWidget(row, 5, actions)
430 def _small_button(self, text: str) -> QPushButton:
431 """A button that fits a compact row (macOS buttons are tall by default)."""
432 button = QPushButton(text)
433 button.setAttribute(Qt.WA_MacSmallSize)
434 button.setAttribute(Qt.WA_LayoutUsesWidgetRect)
435 button.setFixedHeight(self.row_height - 4)
436 return button
438 def show_result(self, row: int, check: DepCheck):
439 self.checks[row] = check
440 self._set_row(row, check)
441 self._done += 1
442 self.table.resizeColumnsToContents()
443 # Size the buttons column to its widest row (cell widgets aren't
444 # counted by resizeColumnsToContents); Status takes the rest.
445 widest = max((w.sizeHint().width() for w in
446 (self.table.cellWidget(r, 5) for r in range(self.table.rowCount())) if w), default=0)
447 hh = self.table.horizontalHeader()
448 hh.setSectionResizeMode(5, QHeaderView.Fixed)
449 self.table.setColumnWidth(5, widest + 8)
450 hh.setSectionResizeMode(4, QHeaderView.Stretch)
451 self._update_summary()
453 def _update_summary(self):
454 total = len(self.checks)
455 if not total:
456 self.summary.setText("<b>This project has no git dependencies.</b>")
457 return
458 if self._done < total:
459 self.summary.setText(f"<b>Checking {total} git dependenc{'ies' if total != 1 else 'y'}…</b>"
460 f" ({self._done} done)")
461 return
462 updates = sum(c.status == UPDATE for c in self.checks)
463 commits = sum(c.status == NEW_COMMITS for c in self.checks)
464 pinned = sum(c.status == TAG_AVAILABLE for c in self.checks)
465 errors = sum(c.status == ERROR for c in self.checks)
466 parts = []
467 if updates: 467 ↛ 469line 467 didn't jump to line 469 because the condition on line 467 was always true
468 parts.append(f"{updates} with a newer tag")
469 if commits: 469 ↛ 470line 469 didn't jump to line 470 because the condition on line 469 was never true
470 parts.append(f"{commits} with new commits")
471 if pinned: 471 ↛ 472line 471 didn't jump to line 472 because the condition on line 471 was never true
472 parts.append(f"{pinned} pinned to a commit with a tag available")
473 if errors: 473 ↛ 474line 473 didn't jump to line 474 because the condition on line 473 was never true
474 parts.append(f"{errors} could not be checked")
475 self.summary.setText("<b>" + (", ".join(parts) if parts else "Everything is up to date") + "</b>")
476 self.update_all.setVisible(commits > 0)
479def start_check(checks: list[DepCheck], dialog: UpdatesDialog, parent: QObject) -> QThread:
480 thread = QThread(parent)
481 worker = CheckWorker(checks)
482 worker.moveToThread(thread)
483 thread.started.connect(worker.run)
484 worker.result.connect(dialog.show_result)
485 worker.finished.connect(thread.quit)
486 thread.finished.connect(worker.deleteLater)
487 thread.finished.connect(thread.deleteLater)
488 thread._worker = worker # keep a reference
489 thread.start()
490 return thread