Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 11 additions & 2 deletions src/ndi/fun/ndi_matlab_python_bridge.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions src/ndi/gui/nav/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -46,4 +47,5 @@
"SessionInfo",
"status_icon",
"statusIcon",
"UpdateDialog",
]
131 changes: 33 additions & 98 deletions src/ndi/gui/nav/ndi_pane.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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):
Expand Down Expand Up @@ -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

Expand All @@ -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."""
Expand Down
Loading
Loading