vibefpmgui.dep_updates

Check git dependencies for newer versions (only when the user asks).

For each git dependency the remote is asked for its tags and branches with git ls-remote (nothing is downloaded), and compared with what the project requests (tag / branch / rev / nothing) and the commit fpm checked out (build/cache.toml). A dependency pinned to an older tag can be moved to the newest tag: only that tag value in the declaring fpm.toml is changed.

  1"""Check git dependencies for newer versions (only when the user asks).
  2
  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"""
  9
 10from __future__ import annotations
 11
 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
 20
 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)
 35
 36from . import manifest as m
 37from .console import NO_WINDOW
 38
 39PRERELEASE_RE = re.compile(r"(alpha|beta|rc|dev|pre|preview|snapshot|nightly)", re.IGNORECASE)
 40LS_REMOTE_TIMEOUT = 30  # seconds per repository
 41
 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"}
 47
 48
 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 = ""
 63
 64    @property
 65    def requested(self) -> str:
 66        if self.ref_kind == "rev":
 67            return f"rev {self.ref[:7]}"
 68        return f"{self.ref_kind} {self.ref}" if self.ref_kind else "default branch"
 69
 70
 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:
 83            spec = _declared_spec(parent.manifest, pkg.name) if parent.manifest else None
 84            if spec is not None:
 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"):
 89                    if key in spec:
 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()))
 95
 96
 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"):
103        spec = (data.get(section) or {}).get(name)
104        if isinstance(spec, dict):
105            return spec
106    return None
107
108
109# -------------------------------------------------------------------- check
110ALLOWED_GIT_PROTOCOLS = "https:http:ssh:git:file"
111
112
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.
119
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
127
128
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
143
144
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
169
170
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)
175
176
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 ""
181
182
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
198
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
231
232
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)):
256                    if lines[j].lstrip().startswith("["):
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):
274        return False
275    manifest.write_text(updated)
276    return True
277
278
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():
285        for dep in folder.iterdir():
286            commit = m.checked_out_commit(dep)
287            if commit:
288                revs[dep.name] = commit
289    return revs
290
291
292# ------------------------------------------------------------------ worker
293class CheckWorker(QObject):
294    result = Signal(int, object)   # row, DepCheck
295    finished = Signal()
296
297    def __init__(self, checks: list[DepCheck]):
298        super().__init__()
299        self.checks = checks
300
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)
316
317
318# ------------------------------------------------------------------ dialog
319class UpdatesDialog(QDialog):
320    """Results of a dependency check, with actions per row."""
321
322    update_tag_requested = Signal(object)   # DepCheck
323    update_deps_requested = Signal()
324
325    COLUMNS: ClassVar[list[str]] = ["Dependency", "Requested", "Current", "Newest", "Status", ""]
326
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()
377
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)
386
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:
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:
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 (
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)
429
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
437
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()
452
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:
468            parts.append(f"{updates} with a newer tag")
469        if commits:
470            parts.append(f"{commits} with new commits")
471        if pinned:
472            parts.append(f"{pinned} pinned to a commit with a tag available")
473        if errors:
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)
477
478
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
PRERELEASE_RE = re.compile('(alpha|beta|rc|dev|pre|preview|snapshot|nightly)', re.IGNORECASE)
LS_REMOTE_TIMEOUT = 30
TAG_AVAILABLE = 'tag available'
STATUS_COLORS = {'up to date': '#2f9e44', 'update': '#e8590c', 'new commits': '#1c7ed6', 'tag available': '#7048e8', 'info': '#868e96', 'error': '#e03131'}
@dataclass
class DepCheck:
50@dataclass
51class DepCheck:
52    name: str
53    git: str
54    ref_kind: str = ""          # "tag", "branch", "rev" or "" (default branch)
55    ref: str = ""
56    current: str = ""           # commit fpm checked out (build/cache.toml)
57    declared_in: Path | None = None
58    editable: bool = False      # the declaring fpm.toml is the user's (root / path dependency)
59    latest_tag: str = ""
60    latest_tag_commit: str = ""
61    branch_commit: str = ""     # newest commit of the requested (or default) branch
62    status: str = ""
63    message: str = ""
64
65    @property
66    def requested(self) -> str:
67        if self.ref_kind == "rev":
68            return f"rev {self.ref[:7]}"
69        return f"{self.ref_kind} {self.ref}" if self.ref_kind else "default branch"
DepCheck( name: str, git: str, ref_kind: str = '', ref: str = '', current: str = '', declared_in: pathlib.Path | None = None, editable: bool = False, latest_tag: str = '', latest_tag_commit: str = '', branch_commit: str = '', status: str = '', message: str = '')
name: str
git: str
ref_kind: str = ''
ref: str = ''
current: str = ''
declared_in: pathlib.Path | None = None
editable: bool = False
latest_tag: str = ''
latest_tag_commit: str = ''
branch_commit: str = ''
status: str = ''
message: str = ''
requested: str
65    @property
66    def requested(self) -> str:
67        if self.ref_kind == "rev":
68            return f"rev {self.ref[:7]}"
69        return f"{self.ref_kind} {self.ref}" if self.ref_kind else "default branch"
def git_dependencies( graph: vibefpmgui.manifest.DependencyGraph) -> list[DepCheck]:
73def git_dependencies(graph: m.DependencyGraph) -> list[DepCheck]:
74    """Every git dependency, with how it is requested and where."""
75    checks = []
76    for pkg in graph.packages.values():
77        if not pkg.git or pkg.kind in (m.ROOT, m.DEV_ONLY):  # (a dependency's dev-dependency isn't fetched)
78            continue
79        check = DepCheck(pkg.name, pkg.git, current=pkg.git_rev)
80        # Prefer a declaration in a manifest the user can edit.
81        parents = [graph.packages[e.parent] for e in graph.parents(pkg.name) if e.parent in graph.packages]
82        parents.sort(key=lambda p: p.kind not in (m.ROOT, m.PATH))
83        for parent in parents:
84            spec = _declared_spec(parent.manifest, pkg.name) if parent.manifest else None
85            if spec is not None:
86                check.declared_in = parent.manifest
87                check.editable = parent.kind in (m.ROOT, m.PATH)
88                check.git = str(spec.get("git", check.git))
89                for key in ("tag", "branch", "rev"):
90                    if key in spec:
91                        check.ref_kind, check.ref = key, str(spec[key])
92                        break
93                break
94        checks.append(check)
95    return sorted(checks, key=lambda c: (not c.editable, c.name.lower()))

Every git dependency, with how it is requested and where.

ALLOWED_GIT_PROTOCOLS = 'https:http:ssh:git:file'
def git_environment(environ=os.environ) -> dict[str, str]:
114def git_environment(environ=os.environ) -> dict[str, str]:
115    """The environment for git commands that must never wait for a prompt:
116    no password questions (HTTPS), and SSH in batch mode (no host-key or
117    passphrase questions: a key in the agent still works; otherwise it fails
118    at once instead of waiting out the timeout). A GIT_SSH_COMMAND or
119    GIT_SSH of the user's own is left as it is.
120
121    Only real transports are allowed: a URL comes from a project's fpm.toml,
122    which may not be trustworthy, and git's "ext::" transport (and other
123    remote helpers) would run the command in the URL."""
124    env = dict(environ, GIT_TERMINAL_PROMPT="0", GIT_ALLOW_PROTOCOL=ALLOWED_GIT_PROTOCOLS)
125    if not env.get("GIT_SSH_COMMAND") and not env.get("GIT_SSH"):
126        env["GIT_SSH_COMMAND"] = "ssh -o BatchMode=yes"
127    return env

The environment for git commands that must never wait for a prompt: no password questions (HTTPS), and SSH in batch mode (no host-key or passphrase questions: a key in the agent still works; otherwise it fails at once instead of waiting out the timeout). A GIT_SSH_COMMAND or GIT_SSH of the user's own is left as it is.

Only real transports are allowed: a URL comes from a project's fpm.toml, which may not be trustworthy, and git's "ext::" transport (and other remote helpers) would run the command in the URL.

def git_error(stderr: str) -> str:
130def git_error(stderr: str) -> str:
131    """What went wrong, from git's message: its first line (the cause, e.g.
132    "Permission denied (publickey)") and its "fatal:" one, with a hint when
133    SSH couldn't ask for a key or a host key (see git_environment)."""
134    lines = [line.strip() for line in stderr.splitlines() if line.strip()]
135    if not lines:
136        return ""
137    fatal = next((line.removeprefix("fatal: ") for line in lines if line.startswith("fatal:")), "")
138    first = lines[0].removeprefix("fatal: ")
139    text = first if not fatal or fatal == first else f"{first} — {fatal}"
140    if "publickey" in stderr or "Host key verification failed" in stderr:
141        text += (" (SSH can't ask for a key or a host's fingerprint here: load the key into ssh-agent "
142                 "and connect once in a terminal, or use the repository's https:// URL)")
143    return text

What went wrong, from git's message: its first line (the cause, e.g. "Permission denied (publickey)") and its "fatal:" one, with a hint when SSH couldn't ask for a key or a host key (see git_environment).

def ls_remote(url: str) -> dict[str, str]:
146def ls_remote(url: str) -> dict[str, str]:
147    """{ref: commit} for HEAD, branches and tags (annotated tags resolved
148    to their commit). Raises RuntimeError with git's message on failure."""
149    env = git_environment()
150    try:
151        # ("--": a URL from fpm.toml that starts with "-", such as
152        # "--upload-pack=<command>", is a URL to git, never an option.)
153        out = subprocess.run(["git", "ls-remote", "--", url, "HEAD", "refs/heads/*", "refs/tags/*"],
154                             capture_output=True, text=True, timeout=LS_REMOTE_TIMEOUT, env=env, check=False,
155                             **NO_WINDOW)
156    except FileNotFoundError:
157        raise RuntimeError("git was not found on PATH")
158    except subprocess.TimeoutExpired:
159        raise RuntimeError(f"no answer from the server within {LS_REMOTE_TIMEOUT} s")
160    if out.returncode != 0:
161        raise RuntimeError(git_error(out.stderr or "") or f"git ls-remote failed ({out.returncode})")
162    refs: dict[str, str] = {}
163    for line in out.stdout.splitlines():
164        sha, _, ref = line.partition("\t")
165        if ref.endswith("^{}"):
166            refs[ref[:-3]] = sha  # the commit an annotated tag points to
167        else:
168            refs.setdefault(ref, sha)
169    return refs

{ref: commit} for HEAD, branches and tags (annotated tags resolved to their commit). Raises RuntimeError with git's message on failure.

def version_key(tag: str) -> tuple:
172def version_key(tag: str) -> tuple:
173    """Sort key for tags like v1.2.3 / 1.2 / release-2.0.1."""
174    numbers = tuple(int(n) for n in re.findall(r"\d+", tag))
175    return (numbers, not PRERELEASE_RE.search(tag), tag)

Sort key for tags like v1.2.3 / 1.2 / release-2.0.1.

def newest_tag(tags: list[str], allow_prerelease: bool = False) -> str:
178def newest_tag(tags: list[str], allow_prerelease: bool = False) -> str:
179    candidates = [t for t in tags if re.search(r"\d", t)
180                  and (allow_prerelease or not PRERELEASE_RE.search(t))]
181    return max(candidates, key=version_key) if candidates else ""
def check_dependency( check: DepCheck) -> DepCheck:
184def check_dependency(check: DepCheck) -> DepCheck:
185    try:
186        refs = ls_remote(check.git)
187    except RuntimeError as exc:
188        check.status, check.message = ERROR, f"Could not reach the repository: {exc}"
189        return check
190    tags = {ref[len("refs/tags/"):]: sha for ref, sha in refs.items() if ref.startswith("refs/tags/")}
191    allow_pre = check.ref_kind == "tag" and bool(PRERELEASE_RE.search(check.ref))
192    check.latest_tag = newest_tag(list(tags), allow_pre)
193    check.latest_tag_commit = tags.get(check.latest_tag, "")
194    if check.ref_kind == "branch":
195        check.branch_commit = refs.get(f"refs/heads/{check.ref}", "")
196    else:
197        check.branch_commit = refs.get("HEAD", "")
198    current = check.current
199
200    if check.ref_kind == "tag":
201        if check.ref not in tags:
202            check.status, check.message = ERROR, f"Tag {check.ref} no longer exists in the repository"
203        elif check.latest_tag and version_key(check.latest_tag) > version_key(check.ref):
204            check.status = UPDATE
205            check.message = f"Newer tag available: {check.ref} → {check.latest_tag}"
206        else:
207            check.status, check.message = UP_TO_DATE, f"{check.ref} is the newest tag"
208    elif check.ref_kind == "rev":
209        pinned = check.ref
210        if not check.latest_tag:
211            check.status, check.message = INFO, "Pinned to a commit (the repository has no tags)"
212        elif check.latest_tag_commit and (check.latest_tag_commit.startswith(pinned)
213                                          or pinned.startswith(check.latest_tag_commit)):
214            check.status = TAG_AVAILABLE
215            check.message = f"Pinned to commit {pinned[:7]}, which is tag {check.latest_tag}"
216        else:
217            check.status = TAG_AVAILABLE
218            check.message = (f"Pinned to a commit; the newest tag is {check.latest_tag} "
219                             "(Compare shows whether it is newer)")
220    else:  # a branch, or the default branch
221        where = f"branch {check.ref}" if check.ref_kind == "branch" else "the default branch"
222        if not check.branch_commit:
223            check.status, check.message = ERROR, f"Could not find {where} in the repository"
224        elif not current:
225            check.status, check.message = INFO, "Not downloaded yet — Update deps fetches it"
226        elif check.branch_commit.startswith(current) or current.startswith(check.branch_commit):
227            check.status, check.message = UP_TO_DATE, f"Up to date with {where}"
228        else:
229            check.status = NEW_COMMITS
230            check.message = f"{where.capitalize()} has new commits — Update deps fetches them"
231    return check
def set_ref( manifest: pathlib.Path, name: str, old_key: str, old: str, new_key: str, new: str) -> bool:
235def set_ref(manifest: Path, name: str, old_key: str, old: str, new_key: str, new: str) -> bool:
236    """Change ``old_key = "old"`` to ``new_key = "new"`` (e.g. a ``rev`` pin
237    to a ``tag``) for one dependency, leaving everything else in the file
238    (comments, layout) untouched. Returns False if the declaration couldn't
239    be found unambiguously."""
240    text = manifest.read_text()
241    tag_re = re.compile(r"(\b)" + re.escape(old_key) + r"""(\s*=\s*)(["'])""" + re.escape(old) + r"""\3""")
242    lines = text.split("\n")
243    target = None
244    # 1. Inline table:  name = { git = "...", tag = "old" }
245    key = re.compile(r"""^\s*(?:["']?)""" + re.escape(name) + r"""(?:["']?)\s*=\s*\{""")
246    for i, line in enumerate(lines):
247        if key.match(line) and tag_re.search(line):
248            target = i
249            break
250    # 2. Table form:  [dependencies.name] ... tag = "old"
251    if target is None:
252        header = re.compile(r"^\s*\[\s*(?:dev-)?dependencies\.(?:[\"']?)" + re.escape(name)
253                            + r"(?:[\"']?)\s*\]")
254        for i, line in enumerate(lines):
255            if header.match(line):
256                for j in range(i + 1, len(lines)):
257                    if lines[j].lstrip().startswith("["):
258                        break
259                    if tag_re.search(lines[j]):
260                        target = j
261                        break
262                break
263    if target is None:
264        return False
265    lines[target] = tag_re.sub(lambda mt: f"{mt.group(1)}{new_key}{mt.group(2)}{mt.group(3)}{new}{mt.group(3)}",
266                               lines[target], count=1)
267    updated = "\n".join(lines)
268    # Make sure the result parses and says what we meant.
269    try:
270        data = tomllib.loads(updated)
271    except tomllib.TOMLDecodeError:
272        return False
273    spec = (data.get("dependencies") or {}).get(name) or (data.get("dev-dependencies") or {}).get(name)
274    if not isinstance(spec, dict) or spec.get(new_key) != new or (old_key != new_key and old_key in spec):
275        return False
276    manifest.write_text(updated)
277    return True

Change old_key = "old" to new_key = "new" (e.g. a rev pin to a tag) for one dependency, leaving everything else in the file (comments, layout) untouched. Returns False if the declaration couldn't be found unambiguously.

def read_cache_revs(project_dir: pathlib.Path) -> dict[str, str]:
280def read_cache_revs(project_dir: Path) -> dict[str, str]:
281    """{dependency: commit} for the downloaded git dependencies, from each
282    checkout itself (build/cache.toml's "rev" isn't updated by fpm update)."""
283    revs: dict[str, str] = {}
284    folder = project_dir / "build" / "dependencies"
285    if folder.is_dir():
286        for dep in folder.iterdir():
287            commit = m.checked_out_commit(dep)
288            if commit:
289                revs[dep.name] = commit
290    return revs

{dependency: commit} for the downloaded git dependencies, from each checkout itself (build/cache.toml's "rev" isn't updated by fpm update).

class CheckWorker(PySide6.QtCore.QObject):
294class CheckWorker(QObject):
295    result = Signal(int, object)   # row, DepCheck
296    finished = Signal()
297
298    def __init__(self, checks: list[DepCheck]):
299        super().__init__()
300        self.checks = checks
301
302    @Slot()
303    def run(self):
304        try:
305            with ThreadPoolExecutor(max_workers=6) as pool:
306                futures = {pool.submit(check_dependency, c): i for i, c in enumerate(self.checks)}
307                for future in as_completed(futures):
308                    row = futures[future]
309                    try:
310                        check = future.result()
311                    except Exception as exc:  # noqa: BLE001 - that row's error, not the end of the check
312                        check = self.checks[row]
313                        check.status, check.message = ERROR, f"Could not be checked: {exc}"
314                    self.result.emit(row, check)
315        finally:
316            self.finished.emit()  # (always: it ends the thread)

QObject(self, /, parent: PySide6.QtCore.QObject | None = None, *, objectName: str | None = None) -> None

CheckWorker(checks: list[DepCheck])
298    def __init__(self, checks: list[DepCheck]):
299        super().__init__()
300        self.checks = checks

__init__(self, /, parent: PySide6.QtCore.QObject | None = None, *, objectName: str | None = None) -> None

Initialize self. See help(type(self)) for accurate signature.

def result(unknown):

The type of the None singleton.

def finished(unknown):

The type of the None singleton.

checks
@Slot()
def run(self):
302    @Slot()
303    def run(self):
304        try:
305            with ThreadPoolExecutor(max_workers=6) as pool:
306                futures = {pool.submit(check_dependency, c): i for i, c in enumerate(self.checks)}
307                for future in as_completed(futures):
308                    row = futures[future]
309                    try:
310                        check = future.result()
311                    except Exception as exc:  # noqa: BLE001 - that row's error, not the end of the check
312                        check = self.checks[row]
313                        check.status, check.message = ERROR, f"Could not be checked: {exc}"
314                    self.result.emit(row, check)
315        finally:
316            self.finished.emit()  # (always: it ends the thread)
staticMetaObject = PySide6.QtCore.QMetaObject("CheckWorker" inherits "QObject": Methods: #4 type=Signal, signature=result(int,PyObject), parameters=int, PyObject #5 type=Signal, signature=finished() #6 type=Slot, signature=run() )
class UpdatesDialog(PySide6.QtWidgets.QDialog):
320class UpdatesDialog(QDialog):
321    """Results of a dependency check, with actions per row."""
322
323    update_tag_requested = Signal(object)   # DepCheck
324    update_deps_requested = Signal()
325
326    COLUMNS: ClassVar[list[str]] = ["Dependency", "Requested", "Current", "Newest", "Status", ""]
327
328    def __init__(self, checks: list[DepCheck], compare_url, parent=None):
329        """``compare_url(git, old, new)``: a web page comparing two commits (or None)."""
330        super().__init__(parent)
331        self.setWindowTitle("Dependency updates")
332        self.checks = checks
333        self._compare_url = compare_url
334        self.summary = QLabel()
335        self.summary.setWordWrap(True)
336        note = QLabel("<span style='color:gray'>Asks each repository for its tags and branches "
337                      "(<code>git ls-remote</code>); nothing is downloaded and no code is sent.</span>")
338        note.setWordWrap(True)
339        self.table = QTableWidget(len(checks), len(self.COLUMNS))
340        self.table.setHorizontalHeaderLabels(self.COLUMNS)
341        self.table.verticalHeader().setVisible(False)
342        self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
343        self.table.setSelectionMode(QAbstractItemView.NoSelection)
344        # Compact: one line per row (long text is elided; tooltips have it all),
345        # fixed row height, no grid, alternating shading.
346        self.table.setWordWrap(False)
347        self.table.setTextElideMode(Qt.ElideRight)
348        self.table.setShowGrid(False)
349        self.table.setAlternatingRowColors(True)
350        self.row_height = self.table.fontMetrics().height() + 12
351        self.table.verticalHeader().setSectionResizeMode(QHeaderView.Fixed)
352        self.table.verticalHeader().setDefaultSectionSize(self.row_height)
353        hh = self.table.horizontalHeader()
354        for col in range(len(self.COLUMNS) - 1):
355            hh.setSectionResizeMode(col, QHeaderView.ResizeToContents)
356        hh.setSectionResizeMode(4, QHeaderView.Stretch)
357        for row, c in enumerate(checks):
358            self._set_row(row, c, checking=True)
359        close = QPushButton("Close")
360        close.clicked.connect(self.accept)
361        self.update_all = QPushButton("Update deps")
362        self.update_all.setToolTip("Run fpm update (fetches new commits of branch dependencies)")
363        self.update_all.clicked.connect(self.update_deps_requested)
364        self.update_all.hide()
365        buttons = QHBoxLayout()
366        buttons.addWidget(self.update_all)
367        buttons.addStretch(1)
368        buttons.addWidget(close)
369        layout = QVBoxLayout(self)
370        layout.addWidget(self.summary)
371        layout.addWidget(note)
372        layout.addWidget(self.table, 1)
373        layout.addLayout(buttons)
374        header = self.table.horizontalHeader().sizeHint().height()
375        self.resize(1120, 150 + header + self.row_height * max(3, min(len(checks), 12)))
376        self._done = 0
377        self._update_summary()
378
379    def _set_row(self, row: int, c: DepCheck, checking: bool = False):
380        def cell(col, text, tip="", color=None):
381            item = QTableWidgetItem(text)
382            if tip:
383                item.setToolTip(tip)
384            if color:
385                item.setForeground(QColor(color))
386            self.table.setItem(row, col, item)
387
388        where = "" if c.declared_in is None else f"declared in {c.declared_in}"
389        cell(0, c.name + ("" if c.editable else "  (indirect)"), f"{c.git}\n{where}")
390        cell(1, c.requested, f"{c.ref_kind} {c.ref}" if c.ref_kind else "the repository's default branch")
391        cell(2, c.current[:7] if c.current else "—", c.current)
392        if checking:
393            cell(3, "…")
394            cell(4, "checking…", color="#868e96")
395            return
396        newest = c.latest_tag or "—"
397        if c.ref_kind in ("branch", "") and c.branch_commit:
398            newest = f"{c.branch_commit[:7]}" + (f"  (tag {c.latest_tag})" if c.latest_tag else "")
399        cell(3, newest, c.latest_tag_commit or c.branch_commit)
400        cell(4, c.message, c.message, STATUS_COLORS.get(c.status))
401        actions = QWidget()
402        row_layout = QHBoxLayout(actions)
403        row_layout.setContentsMargins(4, 0, 4, 0)
404        row_layout.setSpacing(4)
405        new_commit = c.latest_tag_commit if c.status in (UPDATE, TAG_AVAILABLE) else c.branch_commit
406        compare = self._compare_url(c.git, c.current, new_commit) if c.current and new_commit else None
407        if c.status == UPDATE and c.editable:
408            button = self._small_button(f"Update to {c.latest_tag}")
409            button.setToolTip(f"Change tag = \"{c.ref}\" to \"{c.latest_tag}\" in {c.declared_in}")
410            button.clicked.connect(lambda _=False, ch=c: self.update_tag_requested.emit(ch))
411            row_layout.addWidget(button)
412        elif c.status == TAG_AVAILABLE and c.editable:
413            same = c.latest_tag_commit and (c.latest_tag_commit.startswith(c.ref) or c.ref.startswith(c.latest_tag_commit))
414            button = self._small_button(f"{'Use' if same else 'Switch to'} tag {c.latest_tag}")
415            button.setToolTip(f"Replace rev = \"{c.ref[:12]}…\" with tag = \"{c.latest_tag}\" in {c.declared_in}"
416                              + ("" if same else "\n(Compare first: the tag may be older or newer than the pinned commit)"))
417            button.clicked.connect(lambda _=False, ch=c: self.update_tag_requested.emit(ch))
418            row_layout.addWidget(button)
419        elif c.status in (UPDATE, TAG_AVAILABLE):
420            note = QLabel("<span style='color:gray'>declared by another dependency</span>")
421            row_layout.addWidget(note)
422        if compare and c.status in (UPDATE, NEW_COMMITS) or (
423                compare and c.status == TAG_AVAILABLE and not c.latest_tag_commit.startswith(c.current[:7])):
424            link = self._small_button("Compare")
425            link.setToolTip(compare)
426            link.clicked.connect(lambda _=False, u=compare: QDesktopServices.openUrl(QUrl(u)))
427            row_layout.addWidget(link)
428        row_layout.addStretch(1)  # buttons keep their natural width
429        self.table.setCellWidget(row, 5, actions)
430
431    def _small_button(self, text: str) -> QPushButton:
432        """A button that fits a compact row (macOS buttons are tall by default)."""
433        button = QPushButton(text)
434        button.setAttribute(Qt.WA_MacSmallSize)
435        button.setAttribute(Qt.WA_LayoutUsesWidgetRect)
436        button.setFixedHeight(self.row_height - 4)
437        return button
438
439    def show_result(self, row: int, check: DepCheck):
440        self.checks[row] = check
441        self._set_row(row, check)
442        self._done += 1
443        self.table.resizeColumnsToContents()
444        # Size the buttons column to its widest row (cell widgets aren't
445        # counted by resizeColumnsToContents); Status takes the rest.
446        widest = max((w.sizeHint().width() for w in
447                      (self.table.cellWidget(r, 5) for r in range(self.table.rowCount())) if w), default=0)
448        hh = self.table.horizontalHeader()
449        hh.setSectionResizeMode(5, QHeaderView.Fixed)
450        self.table.setColumnWidth(5, widest + 8)
451        hh.setSectionResizeMode(4, QHeaderView.Stretch)
452        self._update_summary()
453
454    def _update_summary(self):
455        total = len(self.checks)
456        if not total:
457            self.summary.setText("<b>This project has no git dependencies.</b>")
458            return
459        if self._done < total:
460            self.summary.setText(f"<b>Checking {total} git dependenc{'ies' if total != 1 else 'y'}…</b>"
461                                 f" ({self._done} done)")
462            return
463        updates = sum(c.status == UPDATE for c in self.checks)
464        commits = sum(c.status == NEW_COMMITS for c in self.checks)
465        pinned = sum(c.status == TAG_AVAILABLE for c in self.checks)
466        errors = sum(c.status == ERROR for c in self.checks)
467        parts = []
468        if updates:
469            parts.append(f"{updates} with a newer tag")
470        if commits:
471            parts.append(f"{commits} with new commits")
472        if pinned:
473            parts.append(f"{pinned} pinned to a commit with a tag available")
474        if errors:
475            parts.append(f"{errors} could not be checked")
476        self.summary.setText("<b>" + (", ".join(parts) if parts else "Everything is up to date") + "</b>")
477        self.update_all.setVisible(commits > 0)

Results of a dependency check, with actions per row.

UpdatesDialog( checks: list[DepCheck], compare_url, parent=None)
328    def __init__(self, checks: list[DepCheck], compare_url, parent=None):
329        """``compare_url(git, old, new)``: a web page comparing two commits (or None)."""
330        super().__init__(parent)
331        self.setWindowTitle("Dependency updates")
332        self.checks = checks
333        self._compare_url = compare_url
334        self.summary = QLabel()
335        self.summary.setWordWrap(True)
336        note = QLabel("<span style='color:gray'>Asks each repository for its tags and branches "
337                      "(<code>git ls-remote</code>); nothing is downloaded and no code is sent.</span>")
338        note.setWordWrap(True)
339        self.table = QTableWidget(len(checks), len(self.COLUMNS))
340        self.table.setHorizontalHeaderLabels(self.COLUMNS)
341        self.table.verticalHeader().setVisible(False)
342        self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
343        self.table.setSelectionMode(QAbstractItemView.NoSelection)
344        # Compact: one line per row (long text is elided; tooltips have it all),
345        # fixed row height, no grid, alternating shading.
346        self.table.setWordWrap(False)
347        self.table.setTextElideMode(Qt.ElideRight)
348        self.table.setShowGrid(False)
349        self.table.setAlternatingRowColors(True)
350        self.row_height = self.table.fontMetrics().height() + 12
351        self.table.verticalHeader().setSectionResizeMode(QHeaderView.Fixed)
352        self.table.verticalHeader().setDefaultSectionSize(self.row_height)
353        hh = self.table.horizontalHeader()
354        for col in range(len(self.COLUMNS) - 1):
355            hh.setSectionResizeMode(col, QHeaderView.ResizeToContents)
356        hh.setSectionResizeMode(4, QHeaderView.Stretch)
357        for row, c in enumerate(checks):
358            self._set_row(row, c, checking=True)
359        close = QPushButton("Close")
360        close.clicked.connect(self.accept)
361        self.update_all = QPushButton("Update deps")
362        self.update_all.setToolTip("Run fpm update (fetches new commits of branch dependencies)")
363        self.update_all.clicked.connect(self.update_deps_requested)
364        self.update_all.hide()
365        buttons = QHBoxLayout()
366        buttons.addWidget(self.update_all)
367        buttons.addStretch(1)
368        buttons.addWidget(close)
369        layout = QVBoxLayout(self)
370        layout.addWidget(self.summary)
371        layout.addWidget(note)
372        layout.addWidget(self.table, 1)
373        layout.addLayout(buttons)
374        header = self.table.horizontalHeader().sizeHint().height()
375        self.resize(1120, 150 + header + self.row_height * max(3, min(len(checks), 12)))
376        self._done = 0
377        self._update_summary()

compare_url(git, old, new): a web page comparing two commits (or None).

def update_tag_requested(unknown):

The type of the None singleton.

def update_deps_requested(unknown):

The type of the None singleton.

COLUMNS: ClassVar[list[str]] = ['Dependency', 'Requested', 'Current', 'Newest', 'Status', '']
checks
summary
table
row_height
update_all
def show_result(self, row: int, check: DepCheck):
439    def show_result(self, row: int, check: DepCheck):
440        self.checks[row] = check
441        self._set_row(row, check)
442        self._done += 1
443        self.table.resizeColumnsToContents()
444        # Size the buttons column to its widest row (cell widgets aren't
445        # counted by resizeColumnsToContents); Status takes the rest.
446        widest = max((w.sizeHint().width() for w in
447                      (self.table.cellWidget(r, 5) for r in range(self.table.rowCount())) if w), default=0)
448        hh = self.table.horizontalHeader()
449        hh.setSectionResizeMode(5, QHeaderView.Fixed)
450        self.table.setColumnWidth(5, widest + 8)
451        hh.setSectionResizeMode(4, QHeaderView.Stretch)
452        self._update_summary()
staticMetaObject = PySide6.QtCore.QMetaObject("UpdatesDialog" inherits "QDialog": Methods: #41 type=Signal, signature=update_tag_requested(PyObject), parameters=PyObject #42 type=Signal, signature=update_deps_requested() )
def start_check( checks: list[DepCheck], dialog: UpdatesDialog, parent: PySide6.QtCore.QObject) -> PySide6.QtCore.QThread:
480def start_check(checks: list[DepCheck], dialog: UpdatesDialog, parent: QObject) -> QThread:
481    thread = QThread(parent)
482    worker = CheckWorker(checks)
483    worker.moveToThread(thread)
484    thread.started.connect(worker.run)
485    worker.result.connect(dialog.show_result)
486    worker.finished.connect(thread.quit)
487    thread.finished.connect(worker.deleteLater)
488    thread.finished.connect(thread.deleteLater)
489    thread._worker = worker  # keep a reference
490    thread.start()
491    return thread