diff --git a/CHANGELOG.md b/CHANGELOG.md index a0fef05c..363f5980 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,22 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Added +- **Update button: a package table with tick boxes** (mirroring + NDI-matlab 19b8ab573 and 749b1a266). The navigator's Update button now lists + NDI-python and each dependency clone in `~/.ndi/tools` in + `ndi.gui.nav.update_dialog`, with installed and latest commits, status, notes + (what updating does to your changes, what blocks it) and a tick box each, and + updates only the ticked packages. Behind it: `ndi.update.packageStatus`, a + `Packages` option for `ndi.update.install` (update named packages only; + changes elsewhere need no consent, and the clone need not be on main unless + NDI-python is named), and `CurrentVersion`/`LatestVersion` on each dependency + in `ndi.update.check`. Pulls now stash and restore your edits in every + repository and merge local commits (`git pull --no-edit`) instead of refusing + them. Bridge entries are added for `updateDialog`, `packageStatus`, + `pullRepo` and `findCalculators` (MATLAB-only: NDI-python has no calculator + clones), and the `ndi.fun.epoch` and `ndi.setup` entries are re-examined + against NDI-matlab main. + - **Navigator: DAQ system browser, cloud dataset id, Assign to workspace, and faster DAQ system loading** (mirroring VH-Lab/NDI-matlab#1058). "Session > DAQ Systems..." opens the new `ndi.gui.nav.DaqSystemBrowser`, a diff --git a/src/ndi/fun/ndi_matlab_python_bridge.yaml b/src/ndi/fun/ndi_matlab_python_bridge.yaml index 15def4de..52582357 100644 --- a/src/ndi/fun/ndi_matlab_python_bridge.yaml +++ b/src/ndi/fun/ndi_matlab_python_bridge.yaml @@ -1161,7 +1161,7 @@ functions: - name: epochid2element type: function matlab_path: "+ndi/+fun/+epoch/epochid2element.m" - matlab_last_sync_hash: "fe64a9f53" + matlab_last_sync_hash: "9821a12e6" python_path: "ndi/fun/epoch.py" input_arguments: - name: session @@ -1208,11 +1208,16 @@ functions: differences that stand: MATLAB returns a cell array the same size as the input where this returns a dict keyed by epoch id, and MATLAB drives a ProgressBarWindow that has no counterpart in a headless port. + NDI-matlab 9821a12e6 assigns the element to every entry of the input + that names the epoch (element(epochInd) = {...}); it used to fail when + the same epoch id appeared more than once. No-op here: the result is a + dict keyed by epoch id, so repeated ids share one entry, which lists + every element claiming that epoch. - name: filename2epochid type: function matlab_path: "+ndi/+fun/+epoch/filename2epochid.m" - matlab_last_sync_hash: "fe64a9f53" + matlab_last_sync_hash: "774a01f71" python_path: "ndi/fun/epoch.py" input_arguments: - name: session @@ -1237,6 +1242,10 @@ functions: dev{d}.epochtable) where the port reads daqsystem.epoch_table out of the stored daq_system documents -- the same table when the documents are current, and stale when they are not. + NDI-matlab 774a01f71 assigns the epoch id to every filename that names + the epoch (epochid(fileInd) = {...}); it used to fail when several + filenames matched one epoch. No-op here: each filename already gets + its own list of matching epoch ids. - name: t0_t1cell2array type: function diff --git a/src/ndi/gui/nav/__init__.py b/src/ndi/gui/nav/__init__.py index bf4326df..5dca42a2 100644 --- a/src/ndi/gui/nav/__init__.py +++ b/src/ndi/gui/nav/__init__.py @@ -31,6 +31,7 @@ from .progress_pane import ProgressPane from .session_info import SessionInfo from .status_icon import status_icon, statusIcon +from .update_dialog import UpdateDialog __all__ = [ "datasets_cloud", @@ -46,4 +47,5 @@ "SessionInfo", "status_icon", "statusIcon", + "UpdateDialog", ] diff --git a/src/ndi/gui/nav/ndi_pane.py b/src/ndi/gui/nav/ndi_pane.py index c569fedc..99dda235 100644 --- a/src/ndi/gui/nav/ndi_pane.py +++ b/src/ndi/gui/nav/ndi_pane.py @@ -3,20 +3,16 @@ MATLAB counterpart: ``src/ndi/+ndi/+gui/+nav/ndiPane.m`` A single-row pane with an "NDI" title and, on the right, a broom button that -clears NDI's in-memory caches, an "Update" button that checks for a newer -NDI-python and offers to install it, and a "Prefs" button that opens the -preferences editor. Always the first pane in the navigator, and it cannot be -collapsed. - -What the Update button says is decided by :func:`update_dialog`, which is -pure (a check result in, a dialog description out) so it is testable without -Qt, the split ndi.gui.nav makes elsewhere. +clears NDI's in-memory caches, an "Update" button that checks for newer +versions of NDI-python and its dependencies and offers to install them (in +:mod:`ndi.gui.nav.update_dialog`'s package table), and a "Prefs" button that +opens the preferences editor. Always the first pane in the navigator, and it +cannot be collapsed. """ from __future__ import annotations import os -import re from typing import Any from .pane import NavPane @@ -28,7 +24,7 @@ "UPDATE_WIDTH", "PREFS_WIDTH", "BUTTON_SPACING", - "update_dialog", + "updated_message", ] #: U+1F9F9 BROOM. MATLAB has to write this as a UTF-16 surrogate pair @@ -48,64 +44,19 @@ ) -UPDATE_TOOLTIP = "Check for a newer version of NDI-python" +UPDATE_TOOLTIP = "Check for a newer version of NDI-python and its dependencies" UPDATE_TITLE = "NDI Update" -def update_dialog(result: dict[str, Any]) -> dict[str, Any]: - """What the Update button shows for an ``ndi.update.check`` RESULT. - - Mirrors the decisions in MATLAB's ``ndiPane.checkForUpdates``. Returns a - dict with ``kind`` -- ``"alert"`` (just tell the user) or ``"confirm"`` - (ask, then install) -- and ``message``, ``success`` (alert icon), - ``accept`` (the confirm button's label) and ``default_cancel`` (Cancel is - the default button, as it is whenever the user's own code is touched). - """ - message = result.get("Message", "") - if not result.get("UpdateAvailable"): - return { - "kind": "alert", - "message": message, - "success": result.get("Status") != "unknown", - } - if not result.get("CanInstall"): - return {"kind": "alert", "message": message, "success": False} - if result.get("InstallType") == "git" and result.get("Branch") != "main": - # ndi.update.install refuses this case; say why up front. - return {"kind": "alert", "message": message, "success": False} - - # The message ends with command-line hints; the dialog replaces them. - message = re.sub(r"\n?Run ndi\.update\.install\(\)[^\n]*", "", message) - message = re.sub(r"\n?Note: you have changed code[^\n]*", "", message) - if result.get("HasModifications"): - from ...update import modificationSummary - - summary = modificationSummary(result) - if any(m.get("BlockingFiles") for m in result.get("Modifications", [])): - # Untracked files sit where the update adds files; git will not - # overwrite them, so updating cannot work until they move. - return { - "kind": "alert", - "message": f"{message}\n\n{summary}\n\nMove or rename the files marked " - '"in the way", then press Update again.', - "success": False, - } - return { - "kind": "confirm", - "message": f"{message}\n\n{summary}\n\nUpdate anyway?", - "accept": "Update anyway", - "default_cancel": True, - } +def updated_message(result: dict[str, Any], packages: list[str]) -> str: + """What the Update button says after installing PACKAGES for a check RESULT.""" + from ...update import packageStatus + + rows, _ = packageStatus(result) + names = [r["Name"] for r in rows if r["Name"] in packages] if result.get("InstallType") == "installer" and os.name == "nt": - message += ( - "\n\nThe NDI installer will open in its own window and finish " "after you close NDI." - ) - return { - "kind": "confirm", - "message": f"{message}\n\nUpdate now?", - "accept": "Update", - "default_cancel": False, - } + return "The NDI installer has opened in a new window. Close NDI to let it finish." + return f"Updated: {', '.join(names)}.\n\nRestart NDI to make sure the new versions are used." class NdiPane(NavPane): @@ -168,12 +119,15 @@ def clear_caches(self) -> list[str]: return cleared def check_for_updates(self) -> dict[str, Any] | None: - """Check for, and offer to install, an NDI-python update (the Update button). - - Runs ``ndi.update.check`` and reports the result. If an update can be - installed, asks the user to confirm and then runs - ``ndi.update.install`` without its terminal prompt. Returns the check - result (None if the check itself failed). + """Check for, and offer to install, NDI updates (the Update button). + + Runs ``ndi.update.check`` and lists every package in + :class:`ndi.gui.nav.update_dialog.UpdateDialog` -- NDI-python and its + dependency clones -- each with its status, notes and a tick box. If + the user presses Update, runs ``ndi.update.install`` for the ticked + packages only, without its terminal prompts (the dialog has already + shown what updating will do). Returns the check result (None if the + check itself failed). """ from ... import update @@ -184,45 +138,26 @@ def check_for_updates(self) -> dict[str, Any] | None: self._alert(str(exc), UPDATE_TITLE, success=False) return None - plan = update_dialog(result) - if plan["kind"] == "alert": - self._alert(plan["message"], UPDATE_TITLE, success=plan["success"]) - return result - if not self._confirm(plan["message"], plan["accept"], plan["default_cancel"]): + go, packages = self._ask(result) + if not go or not packages: return result try: with _busy_cursor(): - update.install(Confirm=False, AllowModified=True) + update.install(Confirm=False, AllowModified=True, Packages=packages) except Exception as exc: # noqa: BLE001 - reported to the user self._alert(str(exc), UPDATE_TITLE, success=False) return result - if result["InstallType"] == "installer" and os.name == "nt": - done = "The NDI installer has opened in a new window. Close NDI to let it finish." - else: - done = "NDI-python was updated. Restart NDI to make sure the new version is used." - self._alert(done, UPDATE_TITLE, success=True) + self._alert(updated_message(result, packages), UPDATE_TITLE, success=True) return result - def _confirm(self, message: str, accept: str, default_cancel: bool) -> bool: - """Ask before updating; blocking, as MATLAB's uiconfirm is.""" + def _ask(self, result: dict[str, Any]) -> tuple[bool, list[str]]: + """Show the package table; blocking, as MATLAB's uiwait is.""" if self.navigator is None or getattr(self.navigator, "figure", None) is None: - return False - from PySide6 import QtWidgets + return False, [] + from .update_dialog import ask - box = QtWidgets.QMessageBox(self.navigator.figure) - box.setWindowTitle(UPDATE_TITLE) - box.setText(message) - box.setIcon( - QtWidgets.QMessageBox.Icon.Warning - if default_cancel - else QtWidgets.QMessageBox.Icon.Question - ) - go = box.addButton(accept, QtWidgets.QMessageBox.ButtonRole.AcceptRole) - cancel = box.addButton("Cancel", QtWidgets.QMessageBox.ButtonRole.RejectRole) - box.setDefaultButton(cancel if default_cancel else go) - box.exec() - return box.clickedButton() is go + return ask(result, self.navigator.figure) def _alert(self, message: str, title: str, *, success: bool) -> None: """Show a message on the navigator figure, if there is one.""" diff --git a/src/ndi/gui/nav/update_dialog.py b/src/ndi/gui/nav/update_dialog.py new file mode 100644 index 00000000..c0c194f6 --- /dev/null +++ b/src/ndi/gui/nav/update_dialog.py @@ -0,0 +1,337 @@ +"""ndi.gui.nav.update_dialog - the package table shown by the Update button. + +MATLAB counterpart: ``src/ndi/+ndi/+gui/+nav/updateDialog.m`` + +A modal window listing each package ``ndi.update.install`` looks at (see +:func:`ndi.update.packageStatus`) for an ``ndi.update.check`` result. For +each package the table shows a tick box saying whether to update it, its +installed and latest version, its status, and its most serious note: + +* info - something to know (restart NDI, a package skipped) +* warning - updating touches your work (edits stashed and put back, local + commits merged) +* error - the update cannot go ahead until you act (an untracked file in + the way, NDI-python not on main) + +Selecting a row shows its notes and the files they are about in the box +under the table. Only ticked packages are updated. Packages that need +updating start ticked, except those where updating touches your own work; +packages that are up to date or blocked cannot be ticked. The Update button +names how many packages are ticked and is greyed out when none are; it reads +"Update anyway" when a ticked package has your changes, and Cancel is then +the default. + +What the window shows is decided by the module-level functions +(:func:`table_data`, :func:`note_text`, :func:`update_button_state`), which +need no Qt, the split ``ndi.gui.nav`` makes elsewhere. +""" + +from __future__ import annotations + +from typing import Any + +__all__ = [ + "COLUMNS", + "UpdateDialog", + "ask", + "note_text", + "table_data", + "update_button_state", +] + +#: Column titles, as MATLAB's updateDialog.Columns. +COLUMNS = ["Update", "Package", "Installed", "Latest", "Status", "Notes"] + +_ORDER = ["info", "warning", "error"] + + +def note_text(row: dict[str, Any]) -> str: + """Notes-column text for ROW: its most serious note, and how many more.""" + if not row["Notes"]: + return "" + k = max(range(len(row["Notes"])), key=lambda i: _ORDER.index(row["NoteLevels"][i])) + txt = row["Notes"][k] + if len(row["Notes"]) > 1: + txt = f"{txt} (+{len(row['Notes']) - 1} more)" + return txt + + +def table_data(rows: list[dict[str, Any]], selected: list[bool]) -> list[list[Any]]: + """The table's cells for ROWS, ticked as SELECTED (one list per row).""" + return [ + [bool(sel), r["Name"], r["Installed"], r["Latest"], r["Status"], note_text(r)] + for r, sel in zip(rows, selected) + ] + + +def update_button_state(rows: list[dict[str, Any]], selected: list[bool]) -> tuple[str, bool]: + """Text and enabled state of the Update button. + + ``"Update (N)"`` for N ticked packages, ``"Update anyway (N)"`` if a + ticked package has your changes, and greyed out for none. + """ + n = sum(bool(s) for s in selected) + if n == 0: + return "Update", False + if any(s and r["TouchesChanges"] for r, s in zip(rows, selected)): + return f"Update anyway ({n})", True + return f"Update ({n})", True + + +def ask(result: dict[str, Any], parent: Any = None) -> tuple[bool, list[str]]: + """Open the dialog and wait for it to close. + + Returns ``(go, packages)``: ``go`` is True if the user pressed Update, and + ``packages`` names the ticked packages (for ``ndi.update.install``'s + ``Packages``). + """ + dialog = UpdateDialog(result, parent=parent) + dialog.exec() + return dialog.choice == "update", dialog.selected_packages() + + +class UpdateDialog: + """The modal package table (a ``QDialog``, built on construction). + + MATLAB:: + + D = ndi.gui.nav.updateDialog(RESULT, 'Parent', FIG) + + Attributes: + rows, summary: What :func:`ndi.update.packageStatus` returned. + selected: Which rows are ticked. + choice: ``"update"`` once Update is pressed, else ``"cancel"``. + dialog, table, details, update_button, cancel_button: The widgets. + """ + + def __init__(self, result: dict[str, Any], parent: Any = None): + from ...update import packageStatus + + self.rows, self.summary = packageStatus(result) + self.selected = [r["Selected"] for r in self.rows] + self.choice = "cancel" + self._building = True + self._build(parent) + self._building = False + + # -- the operations MATLAB's methods expose -------------------------- + + def set_selected(self, row: int, tf: bool) -> None: + """Tick (TF true) or untick table row ROW (0-based). + + A row that cannot be updated stays unticked. + """ + from PySide6 import QtCore + + tf = bool(tf) and self.rows[row]["Selectable"] + self.selected[row] = tf + item = self.table.item(row, 0) + if item is not None: + self._building = True + item.setCheckState( + QtCore.Qt.CheckState.Checked if tf else QtCore.Qt.CheckState.Unchecked + ) + self._building = False + self._refresh_update_button() + + def selected_packages(self) -> list[str]: + """Names of the ticked packages.""" + return [r["Name"] for r, s in zip(self.rows, self.selected) if s] + + def show_details(self, row: int) -> None: + """Show the notes and files of table row ROW (0-based).""" + if 0 <= row < len(self.rows): + self.details.setPlainText(self.rows[row]["Details"]) + + def choose(self, choice: str) -> None: + """Record the user's CHOICE (``"update"`` or ``"cancel"``) and close.""" + self.choice = choice + if choice == "update": + self.dialog.accept() + else: + self.dialog.reject() + + def exec(self) -> int: + """Show the window modally and wait for it to close.""" + return self.dialog.exec() + + # -- MATLAB's property names ------------------------------------------- + + #: Column titles (MATLAB's constant Columns). + Columns = COLUMNS + + @property + def Rows(self) -> list[dict[str, Any]]: # noqa: N802 (MATLAB name) + """The rows from :func:`ndi.update.packageStatus`.""" + return self.rows + + @property + def Summary(self) -> dict[str, Any]: # noqa: N802 (MATLAB name) + """The summary from :func:`ndi.update.packageStatus`.""" + return self.summary + + @property + def Selected(self) -> list[bool]: # noqa: N802 (MATLAB name) + """Which rows are ticked.""" + return self.selected + + @property + def Choice(self) -> str: # noqa: N802 (MATLAB name) + """``"update"`` once Update is pressed, else ``"cancel"``.""" + return self.choice + + @property + def Figure(self) -> Any: # noqa: N802 (MATLAB name) + """The window (a ``QDialog``; MATLAB's uifigure).""" + return self.dialog + + @property + def Table(self) -> Any: # noqa: N802 (MATLAB name) + """The package table (a ``QTableWidget``).""" + return self.table + + @property + def DetailsArea(self) -> Any: # noqa: N802 (MATLAB name) + """The read-only box with the selected row's details.""" + return self.details + + @property + def UpdateButton(self) -> Any: # noqa: N802 (MATLAB name) + """The "Update (N)" / "Update anyway (N)" button.""" + return self.update_button + + @property + def CancelButton(self) -> Any: # noqa: N802 (MATLAB name) + """The "Cancel" / "Close" button.""" + return self.cancel_button + + # -- construction ------------------------------------------------------ + + def _build(self, parent: Any) -> None: + from PySide6 import QtCore, QtGui, QtWidgets + + from ..cloud_colors import cloud_colors, rgb_to_hex + + c = cloud_colors() + dlg = QtWidgets.QDialog(parent) + dlg.setWindowTitle("NDI Update") + dlg.setObjectName("ndiNavigatorUpdate") + dlg.setModal(True) + dlg.resize(780, 460) + dlg.setStyleSheet(f"QDialog {{ background-color: {rgb_to_hex(c.dark_blue)}; }}") + self.dialog = dlg + layout = QtWidgets.QVBoxLayout(dlg) + + message = QtWidgets.QLabel(self.summary["Message"]) + message.setWordWrap(True) + message.setStyleSheet(f"color: {rgb_to_hex(c.white)}; font-weight: bold;") + layout.addWidget(message) + + table = QtWidgets.QTableWidget(len(self.rows), len(COLUMNS)) + table.setObjectName("ndiUpdateTable") + table.setHorizontalHeaderLabels(COLUMNS) + table.verticalHeader().setVisible(False) + table.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectionBehavior.SelectRows) + table.setSelectionMode(QtWidgets.QAbstractItemView.SelectionMode.SingleSelection) + table.setEditTriggers(QtWidgets.QAbstractItemView.EditTrigger.NoEditTriggers) + colors = { + "info": c.light_blue, + "warning": c.warn_amber, + "error": (0.8, 0.1, 0.1), + } + style = dlg.style() + icons = { + "info": style.standardIcon(QtWidgets.QStyle.StandardPixmap.SP_MessageBoxInformation), + "warning": style.standardIcon(QtWidgets.QStyle.StandardPixmap.SP_MessageBoxWarning), + "error": style.standardIcon(QtWidgets.QStyle.StandardPixmap.SP_MessageBoxCritical), + } + for i, cells in enumerate(table_data(self.rows, self.selected)): + r = self.rows[i] + for j, value in enumerate(cells): + if j == 0: + item = QtWidgets.QTableWidgetItem() + flags = QtCore.Qt.ItemFlag.ItemIsEnabled | QtCore.Qt.ItemFlag.ItemIsSelectable + if r["Selectable"]: + flags |= QtCore.Qt.ItemFlag.ItemIsUserCheckable + item.setFlags(flags) + item.setCheckState( + QtCore.Qt.CheckState.Checked if value else QtCore.Qt.CheckState.Unchecked + ) + else: + item = QtWidgets.QTableWidgetItem(str(value)) + # Bold the packages that can be updated. + if r["WillUpdate"]: + font = item.font() + font.setBold(True) + item.setFont(font) + if j == 4 and r["Status"] == "Up to date": + item.setForeground(QtGui.QColor(rgb_to_hex(c.ok_green))) + if j == len(COLUMNS) - 1 and r["NoteLevel"]: + item.setIcon(icons[r["NoteLevel"]]) + item.setForeground(QtGui.QColor(rgb_to_hex(colors[r["NoteLevel"]]))) + table.setItem(i, j, item) + header = table.horizontalHeader() + for j, width in enumerate([55, 170, 75, 75, 130]): + table.setColumnWidth(j, width) + header.setStretchLastSection(True) + table.itemChanged.connect(self._on_item_changed) + table.currentCellChanged.connect(lambda row, *_: self.show_details(row)) + self.table = table + layout.addWidget(table, 1) + + details = QtWidgets.QPlainTextEdit("Select a package to see its notes.") + details.setObjectName("ndiUpdateDetails") + details.setReadOnly(True) + details.setFixedHeight(110) + self.details = details + layout.addWidget(details) + # Start on the package that most needs reading. + first = next((i for i, r in enumerate(self.rows) if r["Blocked"]), None) + if first is None: + first = next( + (i for i, r in enumerate(self.rows) if r["Notes"] and r["WillUpdate"]), None + ) + if first is not None: + self.show_details(first) + + buttons = QtWidgets.QHBoxLayout() + buttons.addStretch(1) + self.update_button = QtWidgets.QPushButton("Update") + self.update_button.setObjectName("ndiUpdateGo") + self.update_button.setStyleSheet( + f"background-color: {rgb_to_hex(c.light_blue)}; color: {rgb_to_hex(c.dark_blue)};" + ) + self.update_button.clicked.connect(lambda: self.choose("update")) + self.cancel_button = QtWidgets.QPushButton( + "Cancel" if self.summary["AnyUpdate"] else "Close" + ) + self.cancel_button.setObjectName("ndiUpdateCancel") + self.cancel_button.setStyleSheet( + f"background-color: {rgb_to_hex(c.white)}; color: {rgb_to_hex(c.dark_blue)};" + ) + self.cancel_button.clicked.connect(lambda: self.choose("cancel")) + buttons.addWidget(self.update_button) + buttons.addWidget(self.cancel_button) + layout.addLayout(buttons) + self._refresh_update_button() + + def _refresh_update_button(self) -> None: + text, enabled = update_button_state(self.rows, self.selected) + self.update_button.setText(text) + self.update_button.setEnabled(enabled) + # Enter should not stash someone's work: Cancel is the default then. + touches = any(s and r["TouchesChanges"] for r, s in zip(self.rows, self.selected)) + default = self.cancel_button if (not enabled or touches) else self.update_button + self.update_button.setDefault(default is self.update_button) + self.cancel_button.setDefault(default is self.cancel_button) + + def _on_item_changed(self, item: Any) -> None: + # A tick box changed. Rows that cannot be updated snap back. + if self._building or item.column() != 0: + return + from PySide6 import QtCore + + row = item.row() + self.set_selected(row, item.checkState() == QtCore.Qt.CheckState.Checked) + if not self.rows[row]["Selectable"]: + self.show_details(row) diff --git a/src/ndi/gui/ndi_matlab_python_bridge.yaml b/src/ndi/gui/ndi_matlab_python_bridge.yaml index 428cc947..d718ab7c 100644 --- a/src/ndi/gui/ndi_matlab_python_bridge.yaml +++ b/src/ndi/gui/ndi_matlab_python_bridge.yaml @@ -748,7 +748,7 @@ ported_late: - name: ndiPane matlab_path: "+ndi/+gui/+nav/ndiPane.m" - matlab_last_sync_hash: "a24f19076" + matlab_last_sync_hash: "749b1a266" python_path: "ndi/gui/nav/ndi_pane.py" decision_log: > The NDI-branded pane. Ported in #88. @@ -767,6 +767,36 @@ ported_late: at a path the update adds a file to are reported, as BlockingFiles (replacing NewFiles), and the Update button shows them in a warning and asks for them to be moved instead of offering the update. Ported the same. + 2026-10-28: NDI-matlab 19b8ab573 / 749b1a266: the Update button lists every + package in updateDialog (status, notes, a tick box each) and installs + the ticked ones with ndi.update.install(Packages=...); the tooltip + names the dependencies too. Ported: check_for_updates opens + ndi.gui.nav.update_dialog and calls install with Packages, then reports + "Updated: ..." with the restart note (on Windows, for an installer + install, that the installer has opened in its own window). The old + pure update_dialog() decisions are gone with MATLAB's old dialogs; + updated_message() builds the closing alert. + + - name: updateDialog + matlab_path: "+ndi/+gui/+nav/updateDialog.m" + matlab_last_sync_hash: "749b1a266" + python_path: "ndi/gui/nav/update_dialog.py" + python_class: "UpdateDialog" + decision_log: > + Ported (NDI-matlab 19b8ab573, 749b1a266). A modal QDialog with the same + summary line, table columns (Update, Package, Installed, Latest, + Status, Notes), bold rows for packages that will update, green "Up to + date", an icon and colour for each row's most serious note, a details + box (starting on the first blocked row, else the first with notes), + and the same Update / Update anyway (N) / Close button rules, with + Cancel the default when a ticked package has the user's changes. + Methods: set_selected, selected_packages, show_details, choose (MATLAB's + setSelected, selectedPackages, showDetails, choose; rows are 0-based); + the static tableData, noteText and updateButtonState are module + functions table_data, note_text and update_button_state, testable + without Qt; ask(result, parent) returns (go, packages). Tick boxes are + checkable only on Selectable rows, so a row that cannot be updated + cannot be ticked rather than snapping back. - name: datasetsPane matlab_path: "+ndi/+gui/+nav/datasetsPane.m" diff --git a/src/ndi/ndi_matlab_python_bridge.yaml b/src/ndi/ndi_matlab_python_bridge.yaml index 09801318..f6f51d38 100644 --- a/src/ndi/ndi_matlab_python_bridge.yaml +++ b/src/ndi/ndi_matlab_python_bridge.yaml @@ -1310,7 +1310,7 @@ not_applicable: # ========================================================================= - name: setup matlab_path: "+ndi/+setup/" - matlab_last_sync_hash: "03c79fa48" + matlab_last_sync_hash: "fd7ddbf06" python_path: "ndi/setup/" status: ported decision_log: > @@ -1344,6 +1344,14 @@ not_applicable: said so. Changing the signature now would break every caller for a cosmetic gain. + Re-examined against NDI-matlab fd7ddbf06. Every change since 03c79fa48 + is in +ndi/+setup/+conv/+vhlab -- the vhlab (Stacy) legacy-data + importer: approach mappings, subject creation, per-experiment and + per-study import, directory validation, spike-timing and mixture + fixes. That converter has no Python port (nothing under ndi/setup + imports legacy lab data), so nothing here follows; lab, labNames and + the per-lab wrappers are unchanged. + # ========================================================================= # ndi.docs namespace — MATLAB documentation generation # ========================================================================= diff --git a/src/ndi/update/__init__.py b/src/ndi/update/__init__.py index 8665e3ca..f29714ae 100644 --- a/src/ndi/update/__init__.py +++ b/src/ndi/update/__init__.py @@ -21,6 +21,7 @@ from .install import UpdateError, install from .install_type import installType from .modification_summary import modificationSummary +from .package_status import packageStatus __all__ = [ "check", @@ -28,5 +29,6 @@ "install", "installType", "modificationSummary", + "packageStatus", "UpdateError", ] diff --git a/src/ndi/update/check.py b/src/ndi/update/check.py index 654dde6a..83e865c7 100644 --- a/src/ndi/update/check.py +++ b/src/ndi/update/check.py @@ -58,6 +58,8 @@ def _empty_result(install_type: str, repo_root: str) -> dict[str, Any]: "HasModifications": False, "Modifications": [], "Dependencies": [], + "Calculators": [], + "CalculatorUpdateAvailable": False, "RepoRoot": repo_root, } @@ -101,7 +103,9 @@ def check( update adds a file to, which git will not overwrite -- and ``CommitsAhead``; other untracked files are left alone and not listed), ``Dependencies`` (``Name``, ``Path``, ``Status``, - ``CommitsBehind``) and ``RepoRoot``. + ``CommitsBehind``, ``CurrentVersion`` and ``LatestVersion`` -- short + commit hashes, or ``""`` -- and ``Url``), ``Calculators``, + ``CalculatorUpdateAvailable`` and ``RepoRoot``. Python-specific Notes: Also ``CanInstall`` (``ndi.update.install`` can update this kind of @@ -110,6 +114,12 @@ def check( reports for this same install. A missing count is None where MATLAB has NaN. MATLAB's ``ReleaseUrl``, ``MltbxUrl`` and ``BothInstalled`` belong to its packaged toolbox, which NDI-python does not have. + ``Calculators`` is always empty and ``CalculatorUpdateAvailable`` + False: MATLAB checks the NDIcalc-*-matlab git clones beside + NDI-matlab, while NDI-python gets the calculator definitions it uses + (ndicalc-vis-matlab) as a pip dependency, updated with NDI-python. + A dependency's ``Url`` is ``""``: the clones in ``~/.ndi/tools`` come + from ``ndi_install.py``, not from a requirements.txt line. """ api = GitHubApi or github_api if RepoRoot: @@ -239,7 +249,9 @@ def _check_dependencies(tools: Path) -> tuple[list[dict[str, Any]], list[dict[st if not tools.is_dir(): return deps, mods for path in sorted(p for p in tools.iterdir() if (p / ".git").exists()): - dep = {"Name": path.name, "Path": str(path), "Status": "unknown", "CommitsBehind": None} + dep = _repo_entry(path.name, path) + _, local_hash = run_git(path, "rev-parse", "HEAD") + dep["CurrentVersion"] = local_hash[:7] fetched, _ = run_git(path, "fetch", "--quiet") counted, behind = run_git(path, "rev-list", "--count", "HEAD..@{u}") ahead_status, ahead = run_git(path, "rev-list", "--count", "@{u}..HEAD") @@ -252,12 +264,27 @@ def _check_dependencies(tools: Path) -> tuple[list[dict[str, Any]], list[dict[st ) ) if fetched == 0 and counted == 0: + _, upstream_hash = run_git(path, "rev-parse", "@{u}") + dep["LatestVersion"] = upstream_hash[:7] dep["CommitsBehind"] = _to_int(behind) dep["Status"] = "behind" if (dep["CommitsBehind"] or 0) > 0 else "upToDate" deps.append(dep) return deps, mods +def _repo_entry(name: str, path: str | Path) -> dict[str, Any]: + """A Dependencies entry, as MATLAB's repoEntry.""" + return { + "Name": name, + "Path": str(path), + "Status": "unknown", + "CommitsBehind": None, + "CurrentVersion": "", + "LatestVersion": "", + "Url": "", + } + + def _local_modifications( name: str, repo: str | Path, commits_ahead: int | None, incoming: list[str] ) -> list[dict[str, Any]]: diff --git a/src/ndi/update/install.py b/src/ndi/update/install.py index f8cf9adc..7a729dc4 100644 --- a/src/ndi/update/install.py +++ b/src/ndi/update/install.py @@ -45,6 +45,7 @@ def install( NdiHome: str = "", GitHubApi: Callable[[str, float], Any] | None = None, Ask: Callable[[str], str] | None = None, + Packages: list[str] | None = None, ) -> dict[str, Any]: """Update NDI-python to the newest version on its main branch. @@ -56,11 +57,12 @@ def install( Checks with :func:`ndi.update.check` and, after asking (``Confirm``), installs the update the way NDI-python was installed: - * **git**: ``git pull --ff-only origin main`` in the clone, then - ``python ndi_install.py --update``, which updates the clones in + * **git**: ``git pull origin main`` in the clone (when it is behind), + then ``python ndi_install.py --update``, which updates the clones in ``~/.ndi/tools`` and reinstalls NDI-python and its dependencies into - this environment. Changed files are stashed for the pull and put back - afterwards. The clone must be on main. If you have changed code + this environment. Changed files are stashed for each pull and put back + afterwards; local commits are merged with the update. The clone must + be on main. If you have changed code (edited tracked files, or local commits) in NDI-python or a dependency, the changes are listed first and you are asked whether to update anyway; the default answer is No. Untracked files of your own @@ -88,6 +90,15 @@ def install( to update (tests). GitHubApi: Passed to :func:`ndi.update.check` (tests). Ask: ``f(prompt) -> reply`` used for questions (default ``input``). + Packages: Update only these packages (default: all that need it): + ``"NDI-python"`` and names of dependencies (as in + :func:`ndi.update.check`'s ``Dependencies``). Named packages that + are up to date are skipped. For a git install, named + dependencies are pulled in ``~/.ndi/tools`` and the others are + not touched; ``"NDI-python"`` pulls main and reinstalls with + ``ndi_install.py`` without updating the dependency clones; and + the clone need not be on main unless ``"NDI-python"`` is named. + Only changes in the named packages need consent. Returns: The dict :func:`ndi.update.check` returned before updating. @@ -98,24 +109,32 @@ def install( ``ndi:update:install:localModifications``, ``ndi:update:install:noInstaller``, ``ndi:update:install:pullFailed``, - ``ndi:update:install:installFailed`` or + ``ndi:update:install:installFailed``, + ``ndi:update:install:unknownPackage`` or ``ndi:update:install:cannotInstall`` (a pip install). Python-specific Notes: MATLAB's ``Dependencies`` option (which ndi_install dependency set) has no counterpart: NDI-python's dependencies are fixed by - pyproject.toml. ``Ask`` stands in for MATLAB's ``input``. + pyproject.toml. ``Ask`` stands in for MATLAB's ``input``. MATLAB's + ``IncludeCalculators`` and ``IncludePython`` have no counterpart + (see :func:`ndi.update.check`); neither do the package names + ``"NDI-matlab"`` and calculator repositories. """ ask = Ask or input result = check( Verbose=True, Timeout=Timeout, RepoRoot=RepoRoot, NdiHome=NdiHome, GitHubApi=GitHubApi ) - if not result["UpdateAvailable"] and not Force: + sel = _select_packages(result, Packages) + kind = result["InstallType"] + if sel["All"]: + if not result["UpdateAvailable"] and not Force: + return result + elif not sel["Ndi"] and not sel["DepNames"]: return result - kind = result["InstallType"] if kind == "git": - _install_git(result, Confirm, AllowModified, ask) + _install_git(result, Confirm, AllowModified, ask, sel) elif kind == "installer": _install_installer(result, Confirm, ask) else: @@ -129,19 +148,34 @@ def install( def _install_git( - result: dict[str, Any], confirm: bool, allow_modified: bool, ask: Callable + result: dict[str, Any], + confirm: bool, + allow_modified: bool, + ask: Callable, + sel: dict[str, Any], ) -> None: + """Update NDI-python and its dependencies, or, with SEL naming packages, just those.""" repo = Path(result["RepoRoot"]) - if result["Branch"] != "main": + if sel["All"]: + pull_ndi = (result["CommitsBehind"] or 0) > 0 + dep_paths = [] + mods = result["Modifications"] + touched = [] + else: + pull_ndi = sel["Ndi"] + dep_paths = [d["Path"] for d in result["Dependencies"] if d["Name"] in sel["DepNames"]] + touched = list(sel["DepNames"]) + (["NDI-python"] if pull_ndi else []) + mods = [m for m in result["Modifications"] if m["Name"] in touched] + + # Updating only dependencies leaves NDI-python's branch alone. + if (sel["All"] or pull_ndi) and result["Branch"] != "main": raise UpdateError( "ndi:update:install:notOnMain", f"NDI-python in {repo} is on branch '{result['Branch']}'. Updates come " "from main; run 'git checkout main' there first.", ) - blocking = [ - str(Path(m["Path"]) / f) for m in result["Modifications"] for f in m["BlockingFiles"] - ] + blocking = [str(Path(m["Path"]) / f) for m in mods for f in m["BlockingFiles"]] if blocking: # git pull would stop part-way rather than overwrite these, so no answer # to "update anyway?" could make the update work. @@ -153,8 +187,8 @@ def _install_git( + "\n".join(f" {f}" for f in blocking), ) - if result["HasModifications"]: - summary = modificationSummary(result, MaxFiles=25) + if mods: + summary = modificationSummary({"Modifications": mods}, MaxFiles=25) if confirm: print(summary) if not _confirmed(ask, "Update anyway? Y/N [N]: ", default=False): @@ -168,46 +202,121 @@ def _install_git( "AllowModified=True) to update anyway.", ) elif confirm: - prompt = ( - f"This will pull main into {repo} and run ndi_install.py --update.\n" - "Continue? Y/N [Y]: " - ) + if sel["All"]: + prompt = ( + f"This will pull main into {repo} and run ndi_install.py --update.\n" + "Continue? Y/N [Y]: " + ) + else: + prompt = f"This will update {', '.join(touched)}.\nContinue? Y/N [Y]: " if not _confirmed(ask, prompt): print("Update cancelled.") return + run_installer = sel["All"] or pull_ndi installer = repo / "ndi_install.py" - if not installer.is_file(): + if run_installer and not installer.is_file(): raise UpdateError( "ndi:update:install:noInstaller", f"Could not find ndi_install.py in {repo}." ) - _, dirty = run_git(repo, "status", "--porcelain", "--untracked-files=no") + if pull_ndi: + print(f"Updating NDI-python in {repo}...") + _pull_repo(repo, "origin", "main") + for path in dep_paths: + print(f"Updating {Path(path).name} in {path}...") + _pull_repo(Path(path)) + + if run_installer: + # With every package, ndi_install.py --update also pulls the clones in + # ~/.ndi/tools; with NDI-python named alone it reinstalls without + # touching them. + args = [sys.executable, str(installer)] + (["--update"] if sel["All"] else []) + done = subprocess.run(args, cwd=str(repo)) + if done.returncode != 0: + raise UpdateError( + "ndi:update:install:installFailed", + f"{installer.name} exited with status {done.returncode} (see above).", + ) + _clear_caches() + print("NDI-python update finished. Restart Python to use the new version.") + + +def _pull_repo(repo: Path, *pull_args: str) -> None: + """Pull a git clone, setting aside and restoring the user's edits. + + MATLAB counterpart: ``+ndi/+update/private/pullRepo.m``. Edits to tracked + files are stashed for the pull and put back afterwards; untracked files + are left alone. If the edits no longer apply cleanly they stay in + ``git stash list`` and a warning says so. Raises + ``ndi:update:install:pullFailed`` if the pull fails (the edits are put + back first). PULL_ARGS go to ``git pull`` (default: the branch's + upstream); local commits are merged with the update. + """ + status, out = run_git(repo, "status", "--porcelain", "--untracked-files=no") + if status != 0: + raise UpdateError( + "ndi:update:install:pullFailed", f"Could not read the git status of {repo}: {out}" + ) stashed = False - if dirty.strip(): + if out.strip(): + print(f"Setting aside your changes in {repo} while it is updated...") + # 'git stash' also succeeds when it stashes nothing, and popping then + # would restore an older, unrelated stash: compare the ref. + _, before = run_git(repo, "rev-parse", "-q", "--verify", "refs/stash") status, out = run_git(repo, "stash") + _, after = run_git(repo, "rev-parse", "-q", "--verify", "refs/stash") if status != 0: - raise UpdateError("ndi:update:install:pullFailed", f"git stash failed: {out}") - stashed = True - status, out = run_git(repo, "pull", "--ff-only", "origin", "main", timeout=600) + raise UpdateError( + "ndi:update:install:pullFailed", + f"Could not set aside your changes in {repo}: {out}", + ) + stashed = bool(after) and after != before + + status, pull_out = run_git(repo, "pull", "--no-edit", *pull_args, timeout=600) + if stashed: - popped, pop_out = run_git(repo, "stash", "pop") - if popped != 0: + popped, _ = run_git(repo, "stash", "pop") + if popped == 0: + print(f"Your changes in {repo} were put back.") + else: print( - "Warning: your stashed changes could not be put back cleanly " - f"({pop_out}). They are still in 'git stash list' in {repo}." + f"Warning: your changes in {repo} could not be put back cleanly after " + f'the update. They are still saved: see "git -C {repo} stash list", ' + 'and use "git stash pop" there once any conflicts are resolved.' ) if status != 0: - raise UpdateError("ndi:update:install:pullFailed", f"git pull failed: {out}") + raise UpdateError("ndi:update:install:pullFailed", f"Updating {repo} failed: {pull_out}") - done = subprocess.run([sys.executable, str(installer), "--update"], cwd=str(repo)) - if done.returncode != 0: + +def _select_packages(result: dict[str, Any], packages: list[str] | None) -> dict[str, Any]: + """Which packages to update, as MATLAB's selectPackages. + + With no PACKAGES, all that need it (``All``). Otherwise only the named + ones; those that are up to date are left alone. + """ + sel: dict[str, Any] = {"All": not packages, "Ndi": False, "DepNames": []} + if sel["All"]: + return sel + dep_names = [d["Name"] for d in result["Dependencies"]] + known = ["NDI-python", *dep_names] + unknown = [p for p in packages if p not in known] + if unknown: raise UpdateError( - "ndi:update:install:installFailed", - f"ndi_install.py --update exited with status {done.returncode} (see above).", + "ndi:update:install:unknownPackage", + f"Unknown package(s): {', '.join(unknown)}. The packages are: {', '.join(known)}.", ) - _clear_caches() - print("NDI-python update finished. Restart Python to use the new version.") + if "NDI-python" in packages: + if result["InstallType"] == "git": + sel["Ndi"] = (result["CommitsBehind"] or 0) > 0 + else: + sel["Ndi"] = bool(result["UpdateAvailable"]) + sel["DepNames"] = [ + d["Name"] + for d in result["Dependencies"] + if d["Name"] in packages and d["Status"] in ("behind", "missing") + ] + return sel def _install_installer(result: dict[str, Any], confirm: bool, ask: Callable) -> None: diff --git a/src/ndi/update/ndi_matlab_python_bridge.yaml b/src/ndi/update/ndi_matlab_python_bridge.yaml index e0150779..a6c3028e 100644 --- a/src/ndi/update/ndi_matlab_python_bridge.yaml +++ b/src/ndi/update/ndi_matlab_python_bridge.yaml @@ -19,7 +19,7 @@ functions: - name: check type: function matlab_path: "+ndi/+update/check.m" - matlab_last_sync_hash: "b6427cc89" + matlab_last_sync_hash: "749b1a266" python_path: "ndi/update/check.py" input_arguments: - name: IncludeDependencies @@ -37,6 +37,9 @@ functions: - name: IncludePython type_matlab: "logical" decision_log: "No counterpart: NDI-python checking NDI-python is the whole function." + - name: IncludeCalculators + type_matlab: "logical" + decision_log: "No counterpart: there are no calculator clones to check (see decision_log)." - name: NdiHome type_python: "str" decision_log: "Python only, for tests: check the installer install under this folder (MATLAB's checkPython has the same option)." @@ -67,11 +70,22 @@ functions: 2026-10-21: NDI-matlab b6427cc89: ndi_install now puts stashed changes back after the pull (git stash pop), as ndi_install.py and this port already did, and only pops a stash it made itself. Comment only here; nothing to port. + 2026-10-28: NDI-matlab 19b8ab573 / 749b1a266 (the Update dialog's package + table): Dependencies entries gain CurrentVersion and LatestVersion + (short commit hashes) and Url -- ported; Url is "" here, since the clones + in ~/.ndi/tools come from ndi_install.py, not a requirements.txt line. + MATLAB also checks the NDIcalc-*-matlab calculator clones beside + NDI-matlab (Calculators, CalculatorUpdateAvailable, IncludeCalculators). + NDI-python has no such clones -- it gets the calculator definitions it + uses (ndicalc-vis-matlab) as a pip dependency, updated with NDI-python -- + so both fields are present and always empty / False. MATLAB's Python + field gains CurrentVersion, LatestVersion and CommitsBehind; this + function is that check, and already returns them. - name: install type: function matlab_path: "+ndi/+update/install.m" - matlab_last_sync_hash: "b6427cc89" + matlab_last_sync_hash: "749b1a266" python_path: "ndi/update/install.py" input_arguments: - name: Confirm @@ -90,6 +104,12 @@ functions: - name: IncludePython type_matlab: "logical" decision_log: "No counterpart (see check)." + - name: IncludeCalculators + type_matlab: "logical" + decision_log: "No counterpart (see check)." + - name: Packages + type_matlab: "string array" + type_python: "list[str] | None" output_arguments: - name: result type_python: "dict" @@ -113,6 +133,19 @@ functions: 2026-10-21: NDI-matlab b6427cc89: ndi_install now puts stashed changes back after the pull (git stash pop), as ndi_install.py and this port already did, and only pops a stash it made itself. The two languages now behave the same. + 2026-10-28: NDI-matlab 19b8ab573 / 749b1a266: Packages updates only the + named packages (unknown names raise ndi:update:install:unknownPackage; + up-to-date ones are skipped; only changes in the named packages need + consent; the clone need not be on main unless "NDI-python" is named), + and each repository is pulled with pullRepo (stash, git pull --no-edit, + stash pop; local commits are merged rather than refused by --ff-only). + Ported. Mapping: "NDI-python" pulls main and runs ndi_install.py + without --update (MATLAB pulls NDI-matlab and runs ndi_install only + for named dependencies); a named dependency is pulled in ~/.ndi/tools, + where the .pth file already points, so no installer run is needed. + With no Packages, ndi_install.py --update runs as before, and main is + pulled only when NDI-python is behind (as MATLAB now pulls NDI-matlab + only when behind). Calculators and "NDI-matlab" have no counterpart. - name: installType type: function @@ -161,7 +194,7 @@ functions: - name: checkOnStartup type: function matlab_path: "+ndi/+update/checkOnStartup.m" - matlab_last_sync_hash: "f9ced4bcd" + matlab_last_sync_hash: "19b8ab573" python_path: "ndi/update/check_on_startup.py" output_arguments: - name: line @@ -175,6 +208,10 @@ functions: off the other's check of a different repository. NDI-python has no ndi_Init: the navigator calls this, on a thread, when it opens; importing ndi never does. Returns the line it printed. + 2026-10-28: NDI-matlab 19b8ab573 also reports calculator updates + (CalculatorUpdateAvailable). Nothing to port: NDI-python has no + calculator clones (see check), and a dependency behind already sets + UpdateAvailable here. - name: checkPython type: function @@ -237,3 +274,57 @@ functions: decision_log: > Private helper; here github_api in the private module _github, on urllib.request instead of webread. + + - name: packageStatus + type: function + matlab_path: "+ndi/+update/packageStatus.m" + matlab_last_sync_hash: "749b1a266" + python_path: "ndi/update/package_status.py" + input_arguments: + - name: result + type_matlab: "struct" + type_python: "dict" + output_arguments: + - name: rows + type_matlab: "struct array" + type_python: "list[dict]" + - name: summary + type_matlab: "struct" + type_python: "dict" + decision_log: > + Ported (NDI-matlab 19b8ab573, 749b1a266). Same row and summary fields, + notes, levels, Selectable/Selected rules and messages. Rows: a git + install lists NDI-python ("ndi") and each ~/.ndi/tools clone + ("dependency"); an installer install is one "installer" row for + NDI-python (MATLAB's "toolbox" row has no counterpart), noting that it + is updated by running the installer again; pip and unknown installs are + one "Could not check" row. No "calculator" rows and no "python" row (see + check). A dependency row's WillUpdate is its own status (behind or + missing): MATLAB also requires UpdateAvailable, which a dependency + behind always sets here. A snake_case alias package_status is bound to + the same function. + + - name: findCalculators + type: function + matlab_path: "+ndi/+update/private/findCalculators.m" + matlab_last_sync_hash: "19b8ab573" + status: matlab_only + decision_log: > + Finds the NDIcalc-*-matlab git clones beside NDI-matlab and on the + MATLAB path, for check's Calculators. NDI-python has no calculator + clones: the calculator definitions it uses come from the + ndicalc-vis-matlab pip dependency, updated with NDI-python. + + - name: pullRepo + type: function + matlab_path: "+ndi/+update/private/pullRepo.m" + matlab_last_sync_hash: "19b8ab573" + python_path: "ndi/update/install.py" + status: ported_differently + decision_log: > + Private helper; here _pull_repo in install.py, beside the UpdateError + it raises. Same steps and messages: stash tracked edits (comparing + refs/stash so an unrelated older stash is never popped), git pull + --no-edit, stash pop with a warning (printed) if the edits no longer + apply, and ndi:update:install:pullFailed on a failed status, stash or + pull. diff --git a/src/ndi/update/package_status.py b/src/ndi/update/package_status.py new file mode 100644 index 00000000..a40ded91 --- /dev/null +++ b/src/ndi/update/package_status.py @@ -0,0 +1,265 @@ +"""ndi.update.packageStatus - one row per package for an update dialog. + +MATLAB counterpart: ``src/ndi/+ndi/+update/packageStatus.m`` +""" + +from __future__ import annotations + +import os +from typing import Any + +__all__ = ["packageStatus", "package_status"] + +_LEVELS = ["", "info", "warning", "error"] + + +def packageStatus(result: dict[str, Any]) -> tuple[list[dict[str, Any]], dict[str, Any]]: + """Describe each package :func:`ndi.update.install` would look at. + + MATLAB:: + + [ROWS, SUMMARY] = ndi.update.packageStatus(RESULT) + + Given RESULT from :func:`ndi.update.check`, lists the packages in the + order an update dialog shows them: + + * git installs: NDI-python, then each dependency clone in ``~/.ndi/tools`` + * installer installs: NDI-python + + Args: + result: The dict returned by :func:`ndi.update.check`. + + Returns: + ``(rows, summary)``. Each row is a dict with MATLAB's fields: ``Name``, + ``Kind`` (``"ndi"``, ``"installer"`` or ``"dependency"``), ``Path``, + ``Installed``, ``Latest``, ``Status`` (short text, e.g. ``"3 new + commits"``), ``WillUpdate``, ``Notes``, ``NoteLevels`` (``"info"``, + ``"warning"`` or ``"error"`` per note), ``NoteLevel`` (the most + serious, or ``""``), ``Details`` (longer text: path, notes, files), + ``Blocked``, ``TouchesChanges`` (updating stashes, merges or + overwrites code you changed), ``Files`` (``"changed: ..."`` / + ``"in the way: ..."``), ``Selectable`` (``WillUpdate`` and not + ``Blocked``) and ``Selected`` (starts ticked: selectable and not + touching your changes). ``summary`` has ``CanUpdate``, ``AnyUpdate``, + ``Blocked``, ``HasUserChanges`` and ``Message``. + + ``WillUpdate``, ``TouchesChanges`` and the notes describe what + happens to a package if it is ticked: ``ndi.update.install(Packages= + ...)`` updates only the packages named. + + Python-specific Notes: + MATLAB's toolbox row has no counterpart; an installer install is one + ``"installer"`` row for NDI-python, updated by running the NDI + installer again. There are no calculator rows (see + :func:`ndi.update.check`) and no separate NDI-python row: NDI-python + is the package being checked. + """ + kind = result.get("InstallType") + if kind == "git": + rows = _git_rows(result) + elif kind == "installer": + rows = [_installer_row(result)] + else: + r = _new_row("NDI-python", "ndi", "") + r["Status"] = "Could not check" + _add_note(r, "info", _first_line(result.get("Message", ""))) + rows = [r] + + for r in rows: + r["Details"] = _details(r) + r["Selectable"] = r["WillUpdate"] and not r["Blocked"] + # Leave out, at first, packages where you have work of your own. + r["Selected"] = r["Selectable"] and not r["TouchesChanges"] + + n = sum(r["Selectable"] for r in rows) + summary: dict[str, Any] = { + "AnyUpdate": any(r["WillUpdate"] for r in rows), + "Blocked": any(r["Blocked"] for r in rows), + "CanUpdate": n > 0, + "HasUserChanges": any(r["TouchesChanges"] for r in rows), + } + if summary["AnyUpdate"] and n == 0: + summary["Message"] = ( + "Updates are available, but none can be installed yet: see the " + "packages marked with an error." + ) + elif summary["AnyUpdate"]: + summary["Message"] = f"{n} package(s) can be updated. Tick the ones to update." + if summary["Blocked"]: + summary["Message"] += " Packages marked with an error cannot be updated yet." + else: + summary["Message"] = "Everything is up to date." + return rows, summary + + +#: Snake-case alias for MATLAB's name; one function under two names. +package_status = packageStatus + + +def _git_rows(result: dict[str, Any]) -> list[dict[str, Any]]: + r = _new_row("NDI-python", "ndi", result.get("RepoRoot", "")) + r["Installed"] = result.get("CurrentVersion", "") + r["Latest"] = result.get("LatestVersion", "") + behind = result.get("CommitsBehind") or 0 + ahead = result.get("CommitsAhead") or 0 + r["WillUpdate"] = behind > 0 + if behind > 0: + r["Status"] = _commits_text(behind) + elif result.get("Status") == "unknown": + r["Status"] = "Could not check" + elif ahead > 0: + r["Status"] = f"{ahead} local commit(s)" + else: + r["Status"] = "Up to date" + if result.get("Status") == "unknown": + _add_note(r, "info", _first_line(result.get("Message", ""))) + if result.get("UpdateAvailable") and result.get("Branch") != "main": + _add_note( + r, + "error", + f"On branch '{result.get('Branch')}', not main. Run 'git checkout main' in " + f"{result.get('RepoRoot')} first.", + ) + r["Blocked"] = True + _modification_notes(r, result, r["WillUpdate"]) + rows = [r] + + for d in result.get("Dependencies", []): + r = _repo_row(d, "dependency") + r["WillUpdate"] = d.get("Status") in ("behind", "missing") + if d.get("Status") == "notGit": + _add_note(r, "info", "Not a git clone, so it is not updated here.") + _modification_notes(r, result, r["WillUpdate"]) + rows.append(r) + return rows + + +def _installer_row(result: dict[str, Any]) -> dict[str, Any]: + r = _new_row("NDI-python", "installer", "") + r["Installed"] = result.get("CurrentVersion", "") + r["Latest"] = result.get("LatestVersion", "") + r["WillUpdate"] = bool(result.get("UpdateAvailable") and result.get("CanInstall")) + status = result.get("Status") + behind = result.get("CommitsBehind") + if status == "updateAvailable": + r["Status"] = _commits_text(behind) if behind else "Update available" + elif status == "upToDate": + r["Status"] = "Up to date" + else: + r["Status"] = "Could not check" + _add_note(r, "info", _first_line(result.get("Message", ""))) + if r["WillUpdate"]: + _add_note(r, "info", "Updated by running the NDI installer again.") + if os.name == "nt": + _add_note( + r, + "info", + "The installer opens in its own window and finishes after you close NDI.", + ) + _add_note(r, "info", "Restart NDI after updating.") + return r + + +def _repo_row(d: dict[str, Any], kind: str) -> dict[str, Any]: + r = _new_row(d.get("Name", ""), kind, d.get("Path", "")) + r["Installed"] = d.get("CurrentVersion", "") + r["Latest"] = d.get("LatestVersion", "") + status = d.get("Status") + r["Status"] = { + "missing": "Not installed; will be added", + "upToDate": "Up to date", + "pinned": "Pinned", + "notFound": "Not found", + "notGit": "Not a git clone", + }.get(status, "Could not check") + if status == "behind": + r["Status"] = _commits_text(d.get("CommitsBehind") or 0) + return r + + +def _modification_notes(r: dict[str, Any], result: dict[str, Any], affected: bool) -> None: + """Notes on what updating does to the user's own changes in R's repository. + + AFFECTED: the update runs git stash and git pull there. + """ + mods = [m for m in result.get("Modifications") or [] if m.get("Name") == r["Name"]] + if not mods: + return + m = mods[0] + changed = list(m.get("ChangedFiles") or []) + blocking = list(m.get("BlockingFiles") or []) + ahead = m.get("CommitsAhead") or 0 + if blocking: + _add_note( + r, + "error", + f"{len(blocking)} untracked file(s) are where the update adds files; " + "move or rename them first.", + ) + r["Blocked"] = True + if affected: + r["TouchesChanges"] = bool(changed) or ahead > 0 + if changed: + _add_note( + r, + "warning", + f"{len(changed)} changed file(s) will be stashed and put back after the update.", + ) + if ahead > 0: + _add_note(r, "warning", f"{ahead} local commit(s) will be merged with the update.") + elif changed or ahead > 0: + _add_note(r, "info", "You have changed code here; it is not touched.") + r["Files"] = [f"changed: {f}" for f in changed] + [f"in the way: {f}" for f in blocking] + + +def _details(r: dict[str, Any]) -> str: + parts = [r["Name"]] + if r["Path"]: + parts.append(r["Path"]) + parts.append(f"Status: {r['Status']}") + if r["Notes"]: + parts.append("") + parts.extend(f"- [{lvl}] {note}" for lvl, note in zip(r["NoteLevels"], r["Notes"])) + if r["Files"]: + shown = r["Files"][:25] + parts.append("") + parts.extend(f" {f}" for f in shown) + if len(r["Files"]) > len(shown): + parts.append(f" ... and {len(r['Files']) - len(shown)} more") + return "\n".join(parts) + + +def _add_note(r: dict[str, Any], level: str, text: str) -> None: + r["Notes"].append(str(text)) + r["NoteLevels"].append(level) + if _LEVELS.index(level) > _LEVELS.index(r["NoteLevel"]): + r["NoteLevel"] = level + + +def _commits_text(n: int) -> str: + return "1 new commit" if n == 1 else f"{n} new commits" + + +def _first_line(text: str) -> str: + return str(text).splitlines()[0] if str(text) else "" + + +def _new_row(name: str, kind: str, folder: str) -> dict[str, Any]: + return { + "Name": str(name), + "Kind": kind, + "Path": str(folder), + "Installed": "", + "Latest": "", + "Status": "", + "WillUpdate": False, + "Notes": [], + "NoteLevels": [], + "NoteLevel": "", + "Details": "", + "Blocked": False, + "TouchesChanges": False, + "Files": [], + "Selectable": False, + "Selected": False, + } diff --git a/tests/test_update_misc.py b/tests/test_update_misc.py index 86fbbce9..60ed882a 100644 --- a/tests/test_update_misc.py +++ b/tests/test_update_misc.py @@ -1,6 +1,6 @@ """ndi.update: modificationSummary, checkOnStartup, installType, the CLI and -the navigator's Update-button decisions (update_dialog), all without Qt or -network. +the navigator's Update pane, all without Qt or network. The package table +the Update button opens is tested in test_update_package_status.py. MATLAB counterparts: TestModificationSummary.m, TestInstallType.m and TestNavigatorLayout/testNdiPaneHasUpdateButton in NDI-matlab's @@ -23,7 +23,6 @@ PREFS_WIDTH, UPDATE_WIDTH, NdiPane, - update_dialog, ) from ndi.update import checkOnStartup, installType, modificationSummary @@ -194,77 +193,3 @@ def refuse(**kw): def test_pane_is_wide_enough_for_three_buttons(): pane = NdiPane() assert pane.right_width() == BROOM_WIDTH + UPDATE_WIDTH + PREFS_WIDTH + 2 * BUTTON_SPACING - - -def test_dialog_up_to_date_is_an_alert(): - plan = update_dialog({"UpdateAvailable": False, "Status": "upToDate", "Message": "fine"}) - assert plan == {"kind": "alert", "message": "fine", "success": True} - - -def test_dialog_unknown_is_a_warning(): - plan = update_dialog({"UpdateAvailable": False, "Status": "unknown", "Message": "offline"}) - assert plan["success"] is False - - -def test_dialog_other_branch_only_explains(): - plan = update_dialog( - {"UpdateAvailable": True, "CanInstall": True, "InstallType": "git", "Branch": "dev"} - ) - assert plan["kind"] == "alert" - assert plan["success"] is False - - -def test_dialog_offers_update_and_drops_terminal_hints(): - plan = update_dialog( - { - "UpdateAvailable": True, - "CanInstall": True, - "InstallType": "git", - "Branch": "main", - "HasModifications": False, - "Message": "An update is available.\nRun ndi.update.install() (or `ndi update`) to update.", - } - ) - assert plan["kind"] == "confirm" - assert plan["accept"] == "Update" - assert plan["default_cancel"] is False - assert "ndi.update.install" not in plan["message"] - assert plan["message"].endswith("Update now?") - - -def test_dialog_with_modified_code_defaults_to_cancel(): - plan = update_dialog( - { - "UpdateAvailable": True, - "CanInstall": True, - "InstallType": "git", - "Branch": "main", - "HasModifications": True, - "Modifications": [mod(changed=["src/a.py"])], - "Message": "An update is available.\nNote: you have changed code in NDI-python.", - } - ) - assert plan["accept"] == "Update anyway" - assert plan["default_cancel"] is True - assert "changed: src/a.py" in plan["message"] - assert "Note: you have changed code" not in plan["message"] - - -def test_dialog_with_untracked_files_in_the_way_only_explains(): - # git will not overwrite them, so the button says to move them instead of - # offering an update that would stop part-way. - plan = update_dialog( - { - "UpdateAvailable": True, - "CanInstall": True, - "InstallType": "git", - "Branch": "main", - "HasModifications": True, - "Modifications": [mod(blocking=["new_module.py"])], - "Message": "An update is available.", - } - ) - assert plan["kind"] == "alert" - assert plan["success"] is False - assert "in the way: new_module.py" in plan["message"] - assert "Move or rename" in plan["message"] diff --git a/tests/test_update_package_status.py b/tests/test_update_package_status.py new file mode 100644 index 00000000..0890fa5c --- /dev/null +++ b/tests/test_update_package_status.py @@ -0,0 +1,372 @@ +"""The Update button's package table: ndi.update.packageStatus, +ndi.update.install(Packages=...), and ndi.gui.nav.update_dialog. + +MATLAB counterparts: ndi.update.packageStatus, ndi.update.install's Packages +option and ndi.gui.nav.updateDialog (VH-Lab/NDI-matlab 19b8ab573, 749b1a266). + +The git tests build throwaway clones exactly as test_update_check_git.py does +(bare repositories standing in for GitHub; no network). The dialog's +decisions are plain functions tested without Qt; the window itself is built +under QT_QPA_PLATFORM=offscreen. +""" + +from __future__ import annotations + +import os +import shutil + +import pytest + +from ndi.gui.nav.update_dialog import note_text, table_data, update_button_state +from ndi.update import UpdateError, check, install, packageStatus +from tests.test_update_check_git import Clones, commit_all, git + +needs_git = pytest.mark.skipif(shutil.which("git") is None, reason="needs the git program") + + +@pytest.fixture +def clones(tmp_path): + return Clones(tmp_path) + + +def run_check(clones): + return check(RepoRoot=str(clones.ndi), NdiHome=str(clones.home), Verbose=False) + + +def run_install(clones, **kw): + return install(RepoRoot=str(clones.ndi), NdiHome=str(clones.home), Confirm=False, **kw) + + +def by_name(rows): + return {r["Name"]: r for r in rows} + + +# ---- packageStatus on git clones --------------------------------------------- + + +@needs_git +class TestPackageStatusGit: + def test_up_to_date(self, clones): + rows, summary = packageStatus(run_check(clones)) + assert [r["Name"] for r in rows] == ["NDI-python", "vhlab-toolbox-python"] + assert [r["Kind"] for r in rows] == ["ndi", "dependency"] + assert all(r["Status"] == "Up to date" for r in rows) + assert not any(r["Selectable"] for r in rows) + assert summary["AnyUpdate"] is False + assert summary["Message"] == "Everything is up to date." + assert rows[1]["Installed"] and rows[1]["Installed"] == rows[1]["Latest"] + + def test_packages_behind_start_ticked(self, clones): + clones.push_upstream("NDI-python") + clones.push_upstream("vhlab-toolbox-python") + r = run_check(clones) + dep = r["Dependencies"][0] + assert dep["CurrentVersion"] != dep["LatestVersion"] + rows, summary = packageStatus(r) + for row in rows: + assert row["Status"] == "1 new commit" + assert row["WillUpdate"] and row["Selectable"] and row["Selected"] + assert row["Installed"] != row["Latest"] + assert summary["Message"] == "2 package(s) can be updated. Tick the ones to update." + + def test_your_changes_are_left_unticked(self, clones): + clones.push_upstream("vhlab-toolbox-python") + (clones.dep / "README.md").write_text("my edit") + rows, summary = packageStatus(run_check(clones)) + dep = by_name(rows)["vhlab-toolbox-python"] + assert dep["TouchesChanges"] is True + assert dep["Selectable"] is True + assert dep["Selected"] is False + assert dep["NoteLevel"] == "warning" + assert "1 changed file(s) will be stashed" in dep["Notes"][0] + assert "changed: README.md" in dep["Files"] + assert "changed: README.md" in dep["Details"] + assert summary["HasUserChanges"] is True + + def test_changes_where_nothing_updates_are_not_touched(self, clones): + clones.push_upstream("vhlab-toolbox-python") + (clones.ndi / "README.md").write_text("my edit") + ndi_row = by_name(packageStatus(run_check(clones))[0])["NDI-python"] + assert ndi_row["WillUpdate"] is False + assert ndi_row["TouchesChanges"] is False + assert ndi_row["Notes"] == ["You have changed code here; it is not touched."] + + def test_untracked_file_in_the_way_blocks(self, clones): + seed = clones.seeds / "NDI-python" + (seed / "new.py").write_text("upstream\n") + commit_all(seed, "adds a file") + git(seed, "push", "-q", "origin", "main") + (clones.ndi / "new.py").write_text("mine\n") + rows, summary = packageStatus(run_check(clones)) + ndi_row = rows[0] + assert ndi_row["Blocked"] and not ndi_row["Selectable"] + assert ndi_row["NoteLevel"] == "error" + assert "in the way: new.py" in ndi_row["Files"] + assert summary["Message"].startswith("Updates are available, but none can be installed") + + def test_other_branch_blocks_ndi_python_only(self, clones): + clones.push_upstream("NDI-python") + clones.push_upstream("vhlab-toolbox-python") + git(clones.ndi, "checkout", "-q", "-b", "myFeature") + rows, summary = packageStatus(run_check(clones)) + assert rows[0]["Blocked"] is True + assert "not main" in rows[0]["Notes"][0] + assert rows[1]["Selectable"] is True + assert summary["Message"].endswith("Packages marked with an error cannot be updated yet.") + + +# ---- packageStatus without git ------------------------------------------------- + + +class TestPackageStatusOther: + def test_installer_install(self): + r = { + "InstallType": "installer", + "Status": "updateAvailable", + "UpdateAvailable": True, + "CanInstall": True, + "CommitsBehind": 3, + "CurrentVersion": "abc1234", + "LatestVersion": "def5678", + "Message": "An update.", + } + rows, summary = packageStatus(r) + assert len(rows) == 1 + row = rows[0] + assert (row["Name"], row["Kind"], row["Status"]) == ( + "NDI-python", + "installer", + "3 new commits", + ) + assert row["Selected"] is True + assert "Restart NDI after updating." in row["Notes"] + assert summary["CanUpdate"] is True + + def test_pip_install_cannot_check(self): + rows, summary = packageStatus({"InstallType": "pip", "Message": "pip says no.\nmore"}) + assert rows[0]["Status"] == "Could not check" + assert rows[0]["Notes"] == ["pip says no."] + assert summary["AnyUpdate"] is False + + +# ---- install(Packages=...) ------------------------------------------------------- + + +@needs_git +class TestInstallPackages: + def test_only_the_named_dependency_is_pulled(self, clones): + clones.push_upstream("NDI-python", text="ndi update") + clones.push_upstream("vhlab-toolbox-python", text="dep update") + run_install(clones, Packages=["vhlab-toolbox-python"]) + assert "dep update" in (clones.dep / "README.md").read_text() + assert "ndi update" not in (clones.ndi / "README.md").read_text() + r = run_check(clones) + assert r["Dependencies"][0]["Status"] == "upToDate" + assert r["CommitsBehind"] == 1 + + def test_dependency_update_ignores_the_ndi_branch_and_its_changes(self, clones): + clones.push_upstream("NDI-python") + clones.push_upstream("vhlab-toolbox-python", text="dep update") + git(clones.ndi, "checkout", "-q", "-b", "myFeature") + (clones.ndi / "README.md").write_text("my edit") + run_install(clones, Packages=["vhlab-toolbox-python"]) # no consent needed + assert "dep update" in (clones.dep / "README.md").read_text() + assert (clones.ndi / "README.md").read_text() == "my edit" + + def test_named_changes_still_need_consent_and_survive(self, clones): + seed = clones.seeds / "vhlab-toolbox-python" + (seed / "notes.txt").write_text("original\n") + commit_all(seed, "notes") + git(seed, "push", "-q", "origin", "main") + git(clones.dep, "pull", "-q") + clones.push_upstream("vhlab-toolbox-python", text="dep update") + (clones.dep / "notes.txt").write_text("mine\n") + with pytest.raises(UpdateError) as err: + run_install(clones, Packages=["vhlab-toolbox-python"]) + assert err.value.identifier == "ndi:update:install:localModifications" + run_install(clones, Packages=["vhlab-toolbox-python"], AllowModified=True) + assert (clones.dep / "notes.txt").read_text() == "mine\n" + assert "dep update" in (clones.dep / "README.md").read_text() + + def test_naming_ndi_python_needs_main_and_the_installer(self, clones): + clones.push_upstream("NDI-python") + with pytest.raises(UpdateError) as err: + run_install(clones, Packages=["NDI-python"]) + assert err.value.identifier == "ndi:update:install:noInstaller" + git(clones.ndi, "checkout", "-q", "-b", "myFeature") + with pytest.raises(UpdateError) as err: + run_install(clones, Packages=["NDI-python"]) + assert err.value.identifier == "ndi:update:install:notOnMain" + + def test_ndi_python_alone_reinstalls_without_updating_dependencies(self, clones): + marker = clones.root / "ran.txt" + seed = clones.seeds / "NDI-python" + (seed / "ndi_install.py").write_text( + "import pathlib, sys\n" + f"pathlib.Path({str(marker)!r}).write_text('ran ' + ' '.join(sys.argv[1:]))\n" + ) + commit_all(seed, "add installer") + git(seed, "push", "-q", "origin", "main") + git(clones.ndi, "pull", "-q") + clones.push_upstream("NDI-python", text="ndi update") + clones.push_upstream("vhlab-toolbox-python", text="dep update") + run_install(clones, Packages=["NDI-python"]) + assert "ndi update" in (clones.ndi / "README.md").read_text() + assert marker.read_text() == "ran " # no --update: the clones are left alone + assert "dep update" not in (clones.dep / "README.md").read_text() + + def test_up_to_date_and_unknown_packages(self, clones): + assert run_install(clones, Packages=["vhlab-toolbox-python"])["UpdateAvailable"] is False + with pytest.raises(UpdateError) as err: + run_install(clones, Packages=["NDI-matlab"]) + assert err.value.identifier == "ndi:update:install:unknownPackage" + assert "NDI-python, vhlab-toolbox-python" in str(err.value) + + +# ---- the dialog's decisions ---------------------------------------------------- + + +def _row(name="P", notes=(), levels=(), touches=False, selectable=True): + return { + "Name": name, + "Installed": "a", + "Latest": "b", + "Status": "1 new commit", + "Notes": list(notes), + "NoteLevels": list(levels), + "TouchesChanges": touches, + "Selectable": selectable, + } + + +class TestDialogDecisions: + def test_note_text_shows_the_most_serious(self): + assert note_text(_row()) == "" + row = _row(notes=["fyi", "bad", "careful"], levels=["info", "error", "warning"]) + assert note_text(row) == "bad (+2 more)" + + def test_table_data(self): + rows = [_row("A", ["fyi"], ["info"]), _row("B")] + assert table_data(rows, [True, False]) == [ + [True, "A", "a", "b", "1 new commit", "fyi"], + [False, "B", "a", "b", "1 new commit", ""], + ] + + def test_update_button_state(self): + rows = [_row("A"), _row("B", touches=True)] + assert update_button_state(rows, [False, False]) == ("Update", False) + assert update_button_state(rows, [True, False]) == ("Update (1)", True) + assert update_button_state(rows, [True, True]) == ("Update anyway (2)", True) + + +# ---- the window -------------------------------------------------------------------- + + +def _qt_or_skip(): + pytest.importorskip("PySide6") + os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + try: + from ndi.gui._qt_helpers import get_or_create_app + + return get_or_create_app() + except Exception as exc: # noqa: BLE001 + pytest.skip(f"no usable Qt platform plugin: {exc}") + + +@needs_git +class TestUpdateDialogWindow: + def test_ticks_buttons_and_details(self, clones): + _qt_or_skip() + from ndi.gui.nav.update_dialog import UpdateDialog + + clones.push_upstream("vhlab-toolbox-python") + (clones.dep / "README.md").write_text("my edit") + d = UpdateDialog(run_check(clones)) + assert d.table.rowCount() == 2 + # NDI-python is up to date: it cannot be ticked, and stays unticked. + d.set_selected(0, True) + assert d.selected[0] is False + # The dependency has your changes: unticked at first, so Update is off + # and Cancel the default. + assert d.selected[1] is False + assert not d.update_button.isEnabled() + assert d.cancel_button.isDefault() + assert "my edit" not in d.details.toPlainText() # files, not contents + assert "README.md" in d.details.toPlainText() + d.set_selected(1, True) + assert d.update_button.text() == "Update anyway (1)" + assert d.cancel_button.isDefault() + assert d.selected_packages() == ["vhlab-toolbox-python"] + d.choose("update") + assert d.choice == "update" + + def test_nothing_to_do_says_close(self, clones): + _qt_or_skip() + from ndi.gui.nav.update_dialog import UpdateDialog + + d = UpdateDialog(run_check(clones)) + assert d.cancel_button.text() == "Close" + assert d.update_button.text() == "Update" + assert not d.update_button.isEnabled() + + +# ---- the navigator's Update button ----------------------------------------------- + + +class _FakeNavigator: + figure = object() + + def __init__(self): + self.alerts = [] + + def alert(self, message, title, *, success=True): + self.alerts.append((message, success)) + + +class TestNdiPaneUpdateButton: + def test_installs_only_the_ticked_packages(self, monkeypatch): + import ndi.update + from ndi.gui.nav.ndi_pane import NdiPane + + result = { + "InstallType": "git", + "Status": "updateAvailable", + "UpdateAvailable": True, + "CommitsBehind": 0, + "Branch": "main", + "RepoRoot": "/x", + "Dependencies": [ + { + "Name": "dep", + "Path": "/d", + "Status": "behind", + "CommitsBehind": 2, + "CurrentVersion": "a", + "LatestVersion": "b", + "Url": "", + } + ], + "Modifications": [], + } + installed = [] + monkeypatch.setattr(ndi.update, "check", lambda **kw: result) + monkeypatch.setattr(ndi.update, "install", lambda **kw: installed.append(kw)) + pane = NdiPane(_FakeNavigator()) + monkeypatch.setattr(pane, "_ask", lambda r: (True, ["dep"])) + assert pane.check_for_updates() is result + assert installed == [{"Confirm": False, "AllowModified": True, "Packages": ["dep"]}] + message, success = pane.navigator.alerts[-1] + assert success and message.startswith("Updated: dep.") + + def test_cancel_installs_nothing(self, monkeypatch): + import ndi.update + from ndi.gui.nav.ndi_pane import NdiPane + + installed = [] + monkeypatch.setattr(ndi.update, "check", lambda **kw: {"InstallType": "pip"}) + monkeypatch.setattr(ndi.update, "install", lambda **kw: installed.append(kw)) + pane = NdiPane(_FakeNavigator()) + monkeypatch.setattr(pane, "_ask", lambda r: (False, [])) + pane.check_for_updates() + assert installed == [] + assert pane.navigator.alerts == []