From a87078360143f8cfcfdf01ab1555ca46b476e165 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 21:03:14 +0000 Subject: [PATCH 1/4] Add ndi.update: check for and install a newer NDI-python Mirrors VH-Lab/NDI-matlab#1038 (branch claude/amazing-clarke-a34o5c). ndi.update.check compares this copy with the latest commit on main: - git clones: git fetch origin main, commits behind/ahead, the ~/.ndi/tools clones ndi_install.py makes, and the user's own changed files, new files and local commits (Modifications); - installer installs: the commit in ~/.ndi/install.json against main via GitHub's compare API, or the commits since the install time; - pip installs: reported as unknown. ndi.update.install refuses a clone not on main, lists the user's changes and asks (default No; with Confirm=False it errors unless AllowModified), then pulls --ff-only with changed files stashed and restored and runs ndi_install.py --update, or re-runs the NDI installer (on Windows in its own window, after this process exits). Caches are cleared afterwards. Also modificationSummary, installType and checkOnStartup. - install-ndi.sh / .ps1 install the exact commit main is at and record it, with the install time, in ~/.ndi/install.json (removed by uninstall); NDI-matlab's ndi.update.checkPython reads the same file. The installer workflow checks the receipt. - `ndi update` and `ndi update --check [--json]`. - An Update button in the navigator's NDI pane; the dialog decisions are in a pure update_dialog() so they are tested without Qt. - The navigator runs the once-a-day check on a thread when it opens. - Preferences Update.CheckOnStartup and Update.CheckIntervalDays (shared with NDI-matlab) and Update.Python.LastCheckTime (NDI-matlab keeps Update.MATLAB.LastCheckTime). - ndi.version() reports the receipt's commit for an installer install. - Bridge: new src/ndi/update bridge (matlab_branch markers until the MATLAB branch merges); ndiPane, preferences and version re-synced. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017Ni686VRCwcmMfvCVcLypp --- .github/workflows/installer.yml | 25 ++ CHANGELOG.md | 17 + install/README.md | 16 +- install/install-ndi.ps1 | 61 +++- install/install-ndi.sh | 49 ++- src/ndi/__init__.py | 16 +- src/ndi/__main__.py | 40 +++ src/ndi/gui/nav/ndi_pane.py | 160 ++++++++- src/ndi/gui/navigator.py | 14 + src/ndi/gui/ndi_matlab_python_bridge.yaml | 15 +- src/ndi/ndi_matlab_python_bridge.yaml | 14 +- src/ndi/preferences.py | 34 ++ src/ndi/update/__init__.py | 32 ++ src/ndi/update/_git.py | 36 ++ src/ndi/update/_github.py | 35 ++ src/ndi/update/check.py | 352 +++++++++++++++++++ src/ndi/update/check_on_startup.py | 84 +++++ src/ndi/update/install.py | 266 ++++++++++++++ src/ndi/update/install_type.py | 107 ++++++ src/ndi/update/modification_summary.py | 60 ++++ src/ndi/update/ndi_matlab_python_bridge.yaml | 229 ++++++++++++ tests/test_gui_nav_pane.py | 16 +- tests/test_update_check_git.py | 251 +++++++++++++ tests/test_update_installer.py | 126 +++++++ tests/test_update_misc.py | 249 +++++++++++++ 25 files changed, 2272 insertions(+), 32 deletions(-) create mode 100644 src/ndi/update/__init__.py create mode 100644 src/ndi/update/_git.py create mode 100644 src/ndi/update/_github.py create mode 100644 src/ndi/update/check.py create mode 100644 src/ndi/update/check_on_startup.py create mode 100644 src/ndi/update/install.py create mode 100644 src/ndi/update/install_type.py create mode 100644 src/ndi/update/modification_summary.py create mode 100644 src/ndi/update/ndi_matlab_python_bridge.yaml create mode 100644 tests/test_update_check_git.py create mode 100644 tests/test_update_installer.py create mode 100644 tests/test_update_misc.py diff --git a/.github/workflows/installer.yml b/.github/workflows/installer.yml index 3ec27cd5..42701d61 100644 --- a/.github/workflows/installer.yml +++ b/.github/workflows/installer.yml @@ -209,6 +209,31 @@ jobs: done "$bin/uv$x" --version + - name: The receipt says what was installed, and ndi.update reads it + working-directory: ${{ runner.temp }} + run: | + if [ "$RUNNER_OS" = Windows ]; then + py="$HOME/.ndi/venvs/ndi/Scripts/python.exe" + else + py="$HOME/.ndi/venvs/ndi/bin/python" + fi + cat "$HOME/.ndi/install.json" + # Written by the installer, read by ndi.update and by NDI-matlab's + # ndi.update.checkPython. git was hidden during the install, so a + # local-checkout install records no commit -- only the time. + "$py" -c " + import re, sys + from ndi.update import installType + kind, info = installType() + r = info['Receipt'] + assert kind == 'installer', kind + assert r is not None, 'no readable ~/.ndi/install.json' + assert r['installer'] in ('install-ndi.sh', 'install-ndi.ps1'), r + assert re.fullmatch(r'\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ', r['installedAt']), r + assert r['commit'] is None or re.fullmatch('[0-9a-f]{40}', r['commit']), r + print('receipt ok:', r) + " + - name: The tool environment is this platform's, and pyraview a release wheel working-directory: ${{ runner.temp }} run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 85b97ee3..c9c3e892 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,23 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Added +- **Checking for, and installing, a newer NDI-python** (mirroring + VH-Lab/NDI-matlab#1038). New `ndi.update`: `check()` compares this copy with + the latest commit on main -- a git clone through `git fetch` (plus the + `~/.ndi/tools` clones `ndi_install.py` makes, and the user's own changed + files, new files and local commits), an installer install through the commit + in `~/.ndi/install.json` -- and `install()` updates it: `git pull --ff-only` + then `ndi_install.py --update`, or the NDI installer again. It refuses a + clone not on main, and lists the user's own changes and asks (default No) + before touching them. `ndi update` / `ndi update --check [--json]` on the + command line, an **Update** button in the navigator's NDI pane, and a quiet + once-a-day check when the navigator opens (`Update.CheckOnStartup`, + `Update.CheckIntervalDays`, shared with NDI-matlab; `Update.Python.LastCheckTime`). + The installers now install the exact commit main is at and record it, with + the install time, in `~/.ndi/install.json` (removed by `--uninstall`), which + NDI-matlab's `ndi.update.checkPython` reads too. `ndi.version()` reports that + commit for an installer install. + - **Users in several NDI Cloud organizations** (mirroring VH-Lab/NDI-matlab#1037). Login no longer keeps only the first organization: it caches the user's whole list -- id, name and upload permission -- as JSON in diff --git a/install/README.md b/install/README.md index 076de456..c4ca0b6d 100644 --- a/install/README.md +++ b/install/README.md @@ -38,13 +38,14 @@ terminal window** and try `ndi check`. Everything goes in a hidden `.ndi` folder in your home folder (`~/.ndi` on macOS and Linux, `%USERPROFILE%\.ndi` on Windows), which -NDI-matlab uses too. The installer owns only three folders there: +NDI-matlab uses too. The installer owns only three folders and one file there: -| folder | what is in it | +| folder or file | what is in it | |---|---| | `.ndi/bin` | the `ndi`, `napariViewGEF` and `napariViewLightsheet` commands, and [uv](https://docs.astral.sh/uv/), the tool that installs them | | `.ndi/python` | a private copy of Python 3.12 | | `.ndi/venvs` | NDI and the packages it needs | +| `.ndi/install.json` | which commit of NDI was installed, and when, so `ndi update` (and NDI-matlab) can tell whether there is a newer one | It never touches anything else in `.ndi`, such as your `NDI_Preferences.json`. Outside `.ndi` it only adds `.ndi/bin` to the end of @@ -55,7 +56,11 @@ on Windows), which makes updates faster; `~/.ndi/bin/uv cache clean` empties it. ## Update -Run the installer again, the same way. It replaces NDI with the latest version. +`ndi update --check` says whether a newer NDI is available, and `ndi update` +installs it (after asking). The navigator's **Update** button does the same, +and NDI-matlab's `ndi.update.install()` / Update button update this +installation too. Or run the installer again, the same way: it replaces NDI +with the latest version. ## Uninstall @@ -68,8 +73,9 @@ powershell -ExecutionPolicy Bypass -NoProfile -c "& ([scriptblock]::Create((irm ``` (or run `install-ndi.sh --uninstall` / `install-ndi.ps1 -Uninstall` from this -folder). This removes `.ndi/bin`, `.ndi/python`, `.ndi/venvs` and the `PATH` -lines, and leaves the rest of `.ndi` (your settings) alone. +folder). This removes `.ndi/bin`, `.ndi/python`, `.ndi/venvs`, +`.ndi/install.json` and the `PATH` lines, and leaves the rest of `.ndi` (your +settings) alone. ## Options diff --git a/install/install-ndi.ps1 b/install/install-ndi.ps1 index 012b416b..c2364580 100644 --- a/install/install-ndi.ps1 +++ b/install/install-ndi.ps1 @@ -10,10 +10,13 @@ the ndi, napariViewGEF and napariViewLightsheet commands. WHAT IT TOUCHES. Everything lives under %USERPROFILE%\.ndi, which NDI-matlab - shares, and this script owns ONLY three folders there: - .ndi\bin uv itself, plus ndi.exe / napariViewGEF.exe / napariViewLightsheet.exe - .ndi\python the Python interpreter uv downloads (UV_PYTHON_INSTALL_DIR) - .ndi\venvs the environment NDI is installed into (UV_TOOL_DIR) + shares, and this script owns ONLY three folders and one file there: + .ndi\bin uv itself, plus ndi.exe / napariViewGEF.exe / napariViewLightsheet.exe + .ndi\python the Python interpreter uv downloads (UV_PYTHON_INSTALL_DIR) + .ndi\venvs the environment NDI is installed into (UV_TOOL_DIR) + .ndi\install.json which commit was installed, and when, so that + ndi.update (and NDI-matlab's ndi.update.checkPython) + can tell whether main has moved on since Everything else in .ndi (NDI_Preferences.json, tools, caches, ...) is never touched, by install or by -Uninstall. Outside .ndi it changes only your user-level PATH (unless -NoPath); and uv keeps its download cache in its @@ -65,6 +68,9 @@ $NdiHome = Join-Path $env:USERPROFILE '.ndi' $Bin = Join-Path $NdiHome 'bin' $PyDir = Join-Path $NdiHome 'python' $Venvs = Join-Path $NdiHome 'venvs' +$Receipt = Join-Path $NdiHome 'install.json' +$CommitsApi = 'https://api.github.com/repos/Waltham-Data-Science/NDI-python/commits/main' +$ArchiveBase = 'https://github.com/Waltham-Data-Science/NDI-python/archive' function Fail([string]$Reason) { # One line saying why, and a nonzero exit. [Console]::Error so it is @@ -138,13 +144,17 @@ function Remove-NdiFromPath { # ---- uninstall --------------------------------------------------------------- if ($Uninstall) { - Write-Host "Removing NDI from $NdiHome (bin, python, venvs) ..." + Write-Host "Removing NDI from $NdiHome (bin, python, venvs, install.json) ..." foreach ($d in @($Bin, $PyDir, $Venvs)) { if (Test-Path -LiteralPath $d) { try { Remove-Item -LiteralPath $d -Recurse -Force } catch { Fail "could not remove $d (is an NDI program still running?)" } } } + if (Test-Path -LiteralPath $Receipt) { + try { Remove-Item -LiteralPath $Receipt -Force } + catch { Fail "could not remove $Receipt" } + } Remove-NdiFromPath Write-Host "Your NDI settings and anything else in $NdiHome were left in place." Write-Output "NDI uninstalled: $Bin" @@ -201,8 +211,31 @@ $env:UV_PYTHON_PREFERENCE = 'only-managed' # never a system/Store/conda Python t $env:UV_NO_CONFIG = '1' # ignore any uv.toml of yours $env:UV_NO_MODIFY_PATH = '1' +# Which commit is being installed, for .ndi\install.json (step 4). For the +# default source, ask GitHub for main's current commit and install exactly +# that commit's archive, so the receipt cannot name a different commit from +# the one installed. If GitHub's API cannot be reached (offline, rate limit), +# install main as before and record no commit; ndi.update then falls back to +# the install time. A local checkout records its HEAD when git is there. +$Commit = $null $Source = [Environment]::GetEnvironmentVariable('NDI_INSTALL_SOURCE') -if (-not $Source) { $Source = $DefaultSource } +if (-not $Source) { + $Source = $DefaultSource + try { + $sha = (Invoke-RestMethod -Uri $CommitsApi -TimeoutSec 30 -UseBasicParsing).sha + if ($sha -match '^[0-9a-f]{40}$') { + $Commit = $sha + $Source = "$ArchiveBase/$Commit.zip" + } + } catch { + Write-Host ' (could not ask GitHub which commit main is at; installing main)' + } +} elseif ((Test-Path -LiteralPath $Source -PathType Container) -and (Get-Command git -ErrorAction SilentlyContinue)) { + $ErrorActionPreference = 'Continue' + $head = & git -C $Source rev-parse HEAD 2>$null + if ($LASTEXITCODE -eq 0 -and $head -match '^[0-9a-f]{40}$') { $Commit = $head } + $ErrorActionPreference = 'Stop' +} if (Test-Path -LiteralPath $Source) { # A local checkout (CI): a file:/// URL to its absolute path. $Source = ([System.Uri](Resolve-Path -LiteralPath $Source).ProviderPath).AbsoluteUri @@ -253,7 +286,21 @@ if ($checkExit -ne 0) { } Write-Host ' ndi check: all checks passed' -# 4. PATH. +# 4. The receipt: what was installed, from where, and when. Written only +# after the check passed, so it never describes an install that failed. +# UTF-8 without a byte-order mark: Windows PowerShell 5.1's -Encoding UTF8 +# writes one, and JSON readers (Python's json, MATLAB's jsondecode) reject it. +$receiptJson = [ordered]@{ + commit = $Commit + source = $Source + installedAt = (Get-Date).ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss'Z'") + installer = 'install-ndi.ps1' +} | ConvertTo-Json +try { [System.IO.File]::WriteAllText($Receipt, $receiptJson, (New-Object System.Text.UTF8Encoding $false)) } +catch { Fail "could not write $Receipt" } +if ($Commit) { Write-Host " installed commit $Commit" } + +# 5. PATH. if (-not $NoPath) { Add-NdiToPath Write-Host 'Open a new terminal window to use the ndi, napariViewGEF and napariViewLightsheet commands.' diff --git a/install/install-ndi.sh b/install/install-ndi.sh index 7cca2a4b..2fbd71f4 100755 --- a/install/install-ndi.sh +++ b/install/install-ndi.sh @@ -13,10 +13,13 @@ # --uninstall remove what this script installed, and nothing else # # WHAT IT TOUCHES. Everything lives under ~/.ndi, which NDI-matlab shares, -# and this script owns ONLY three folders there: -# ~/.ndi/bin uv itself, plus the ndi / napariViewGEF / napariViewLightsheet commands -# ~/.ndi/python the Python interpreter uv downloads (UV_PYTHON_INSTALL_DIR) -# ~/.ndi/venvs the environment NDI is installed into (UV_TOOL_DIR) +# and this script owns ONLY three folders and one file there: +# ~/.ndi/bin uv itself, plus the ndi / napariViewGEF / napariViewLightsheet commands +# ~/.ndi/python the Python interpreter uv downloads (UV_PYTHON_INSTALL_DIR) +# ~/.ndi/venvs the environment NDI is installed into (UV_TOOL_DIR) +# ~/.ndi/install.json which commit was installed, and when, so that +# ndi.update (and NDI-matlab's ndi.update.checkPython) +# can tell whether main has moved on since # Everything else in ~/.ndi (NDI_Preferences.json, tools/, caches, ...) is # never touched, by install or by --uninstall. Outside ~/.ndi it edits only # your shell start-up files, inside a marked block (see add_to_path), unless @@ -53,6 +56,9 @@ NDI_HOME="$HOME/.ndi" BIN="$NDI_HOME/bin" PYDIR="$NDI_HOME/python" VENVS="$NDI_HOME/venvs" +RECEIPT="$NDI_HOME/install.json" +COMMITS_API="https://api.github.com/repos/Waltham-Data-Science/NDI-python/commits/main" +ARCHIVE_BASE="https://github.com/Waltham-Data-Science/NDI-python/archive" PATH_BEGIN="# >>> NDI: added by install-ndi.sh; remove with install-ndi.sh --uninstall >>>" PATH_END="# <<< NDI <<<" @@ -184,12 +190,13 @@ remove_from_path() { # ---- uninstall --------------------------------------------------------------- if [ "$UNINSTALL" = 1 ]; then - say "Removing NDI from $NDI_HOME (bin, python, venvs) ..." + say "Removing NDI from $NDI_HOME (bin, python, venvs, install.json) ..." for d in "$BIN" "$PYDIR" "$VENVS"; do # uv marks some of its files read-only; make them removable first. if [ -d "$d" ]; then chmod -R u+w "$d" 2>/dev/null || true; fi rm -rf "$d" || fail "could not remove $d" done + rm -f "$RECEIPT" || fail "could not remove $RECEIPT" remove_from_path say "Your NDI settings and anything else in $NDI_HOME were left in place." say "NDI uninstalled: $BIN" @@ -258,7 +265,27 @@ export UV_PYTHON_PREFERENCE=only-managed # never a system/Homebrew/conda Python export UV_NO_CONFIG=1 # ignore any uv.toml of yours export UV_NO_MODIFY_PATH=1 +# Which commit is being installed, for ~/.ndi/install.json (step 4). For the +# default source, ask GitHub for main's current commit and install exactly +# that commit's archive, so the receipt cannot name a different commit from +# the one installed. If GitHub's API cannot be reached (offline, rate limit), +# install main as before and record no commit; ndi.update then falls back to +# the install time. A local checkout records its HEAD when git is there. +COMMIT="" SOURCE="${NDI_INSTALL_SOURCE:-$DEFAULT_SOURCE}" +if [ -z "${NDI_INSTALL_SOURCE:-}" ]; then + tmp=$(mktemp) + if download "$COMMITS_API" "$tmp" 2>/dev/null; then + # The commit's own sha is the first "sha" at the top level (2 spaces in). + COMMIT=$(sed -n 's/^ "sha": "\([0-9a-f]\{40\}\)".*$/\1/p' "$tmp" | head -n 1) + fi + rm -f "$tmp" + if [ -n "$COMMIT" ]; then + SOURCE="$ARCHIVE_BASE/$COMMIT.zip" + fi +elif [ -d "$SOURCE" ] && command -v git >/dev/null 2>&1; then + COMMIT=$(git -C "$SOURCE" rev-parse HEAD 2>/dev/null || true) +fi if [ -d "$SOURCE" ]; then # A local checkout (CI): a file URL to its absolute path. SOURCE="file://$(cd "$SOURCE" && pwd -P | sed 's/ /%20/g')" @@ -303,7 +330,17 @@ fi rm -f "$log" say " ndi check: all checks passed" -# 4. PATH. +# 4. The receipt: what was installed, from where, and when. Written only +# after the check passed, so it never describes an install that failed. +if [ -n "$COMMIT" ]; then commit_json="\"$COMMIT\""; else commit_json=null; fi +printf '{\n "commit": %s,\n "source": "%s",\n "installedAt": "%s",\n "installer": "install-ndi.sh"\n}\n' \ + "$commit_json" "$SOURCE" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$RECEIPT" \ + || fail "could not write $RECEIPT" +if [ -n "$COMMIT" ]; then + say " installed commit $COMMIT" +fi + +# 5. PATH. if [ "$NO_PATH" = 0 ]; then add_to_path say "Open a new terminal window to use the ndi, napariViewGEF and napariViewLightsheet commands." diff --git a/src/ndi/__init__.py b/src/ndi/__init__.py index 1b2b3dd1..33d9a9a9 100644 --- a/src/ndi/__init__.py +++ b/src/ndi/__init__.py @@ -88,8 +88,11 @@ def version() -> tuple: Returns: Tuple of ``(version_string, url)``. The version string is the - Git commit hash when running inside a checkout, otherwise the - package version. + Git commit hash when running inside a checkout; for a copy the NDI + installer put in ``~/.ndi``, the commit its ``install.json`` records + (MATLAB likewise reports an installed toolbox's release version); + otherwise the package version. To check for a newer version, see + :func:`ndi.update.check`. """ import subprocess from pathlib import Path as _Path @@ -109,6 +112,15 @@ def version() -> tuple: return result.stdout.strip(), url except Exception: pass + try: + from .update.install_type import installType + + kind, info = installType() + commit = (info.get("Receipt") or {}).get("commit") if kind == "installer" else None + if commit: + return str(commit)[:7], url + except Exception: + pass return __version__, url diff --git a/src/ndi/__main__.py b/src/ndi/__main__.py index 2d393c95..8b29d98e 100644 --- a/src/ndi/__main__.py +++ b/src/ndi/__main__.py @@ -18,6 +18,7 @@ def main() -> int: print("Commands:") print(" check Validate NDI installation") print(" version Show version information") + print(" update Update NDI-python (--check: only report; --help: options)") print(" napari View a spatial gene expression pyramid") return 0 @@ -33,6 +34,8 @@ def main() -> int: from ndi.gui.app.genepyramid.cli import main as napari_main return napari_main() + elif command == "update": + return _update_main(sys.argv[1:]) elif command == "version": from ndi import version @@ -46,5 +49,42 @@ def main() -> int: return 1 +def _update_main(argv: list[str]) -> int: + """``ndi update``: check for, and install, a newer NDI-python.""" + import argparse + import json + + parser = argparse.ArgumentParser( + prog="ndi update", + description="Update NDI-python to the latest version on its main branch.", + ) + parser.add_argument("--check", action="store_true", help="only report; change nothing") + parser.add_argument( + "--json", action="store_true", help="with --check, print the full result as JSON" + ) + parser.add_argument("--yes", action="store_true", help="do not ask before updating") + parser.add_argument( + "--allow-modified", + action="store_true", + help="with --yes, update even if you have changed NDI-python's code", + ) + parser.add_argument("--force", action="store_true", help="reinstall even if up to date") + args = parser.parse_args(argv) + + from ndi import update + + if args.check: + result = update.check(Verbose=not args.json) + if args.json: + print(json.dumps(result, indent=2)) + return 0 + try: + update.install(Confirm=not args.yes, AllowModified=args.allow_modified, Force=args.force) + except update.UpdateError as exc: + print(exc, file=sys.stderr) + return 1 + return 0 + + if __name__ == "__main__": sys.exit(main()) diff --git a/src/ndi/gui/nav/ndi_pane.py b/src/ndi/gui/nav/ndi_pane.py index 442a2083..67dbff2d 100644 --- a/src/ndi/gui/nav/ndi_pane.py +++ b/src/ndi/gui/nav/ndi_pane.py @@ -3,18 +3,33 @@ 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 followed by a "Prefs" button that opens the +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. """ from __future__ import annotations +import os +import re from typing import Any from .pane import NavPane -__all__ = ["NdiPane", "BROOM", "BROOM_WIDTH", "PREFS_WIDTH", "BUTTON_SPACING"] +__all__ = [ + "NdiPane", + "BROOM", + "BROOM_WIDTH", + "UPDATE_WIDTH", + "PREFS_WIDTH", + "BUTTON_SPACING", + "update_dialog", +] #: U+1F9F9 BROOM. MATLAB has to write this as a UTF-16 surrogate pair #: (char([55358 56825])) because it is above the BMP and a bare char(129529) @@ -23,6 +38,7 @@ BROOM = "\U0001f9f9" BROOM_WIDTH = 28 +UPDATE_WIDTH = 60 PREFS_WIDTH = 60 BUTTON_SPACING = 4 @@ -32,17 +48,68 @@ ) +UPDATE_TOOLTIP = "Check for a newer version of NDI-python" +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 + + return { + "kind": "confirm", + "message": f"{message}\n\n{modificationSummary(result)}\n\nUpdate anyway?", + "accept": "Update anyway", + "default_cancel": True, + } + 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, + } + + class NdiPane(NavPane): - """The top "NDI" pane: a broom and a Prefs button.""" + """The top "NDI" pane: a broom, an Update and a Prefs button.""" def __init__(self, navigator: Any = None): super().__init__(navigator, title="NDI", collapsible=False) self.broom_button: Any = None + self.update_button: Any = None self.prefs_button: Any = None def right_width(self) -> float: - """broom (28) + spacing (4) + Prefs (60).""" - return BROOM_WIDTH + BUTTON_SPACING + PREFS_WIDTH + """broom (28) + spacing (4) + Update (60) + spacing (4) + Prefs (60).""" + return BROOM_WIDTH + BUTTON_SPACING + UPDATE_WIDTH + BUTTON_SPACING + PREFS_WIDTH def build_header_right(self, layout: Any) -> None: from PySide6 import QtWidgets @@ -54,6 +121,13 @@ def build_header_right(self, layout: Any) -> None: self.accent_button(self.broom_button) layout.addWidget(self.broom_button) + self.update_button = QtWidgets.QPushButton("Update") + self.update_button.setFixedWidth(UPDATE_WIDTH) + self.update_button.setToolTip(UPDATE_TOOLTIP) + self.update_button.clicked.connect(self.check_for_updates) + self.accent_button(self.update_button) + layout.addWidget(self.update_button) + self.prefs_button = QtWidgets.QPushButton("Prefs") self.prefs_button.setFixedWidth(PREFS_WIDTH) self.prefs_button.setToolTip("Open the NDI preferences editor") @@ -83,6 +157,63 @@ def clear_caches(self) -> list[str]: self._alert("NDI in-memory caches were cleared.", "Clear Caches", success=True) 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). + """ + from ... import update + + try: + with _busy_cursor(): + result = update.check(Verbose=False) + except Exception as exc: # noqa: BLE001 - reported to the user + 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"]): + return result + + try: + with _busy_cursor(): + update.install(Confirm=False, AllowModified=True) + 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) + return result + + def _confirm(self, message: str, accept: str, default_cancel: bool) -> bool: + """Ask before updating; blocking, as MATLAB's uiconfirm is.""" + if self.navigator is None or getattr(self.navigator, "figure", None) is None: + return False + from PySide6 import QtWidgets + + 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 + def _alert(self, message: str, title: str, *, success: bool) -> None: """Show a message on the navigator figure, if there is one.""" if self.navigator is None: @@ -90,3 +221,22 @@ def _alert(self, message: str, title: str, *, success: bool) -> None: alert = getattr(self.navigator, "alert", None) if callable(alert): alert(message, title, success=success) + + +class _busy_cursor: + """A wait cursor while the check or the install runs, if Qt is up.""" + + def __enter__(self) -> None: + self._app = None + try: + from PySide6 import QtCore, QtGui, QtWidgets + + self._app = QtWidgets.QApplication.instance() + if self._app is not None: + self._app.setOverrideCursor(QtGui.QCursor(QtCore.Qt.CursorShape.WaitCursor)) + except ImportError: + self._app = None + + def __exit__(self, *exc: object) -> None: + if self._app is not None: + self._app.restoreOverrideCursor() diff --git a/src/ndi/gui/navigator.py b/src/ndi/gui/navigator.py index 0b2c951b..ad5f5797 100644 --- a/src/ndi/gui/navigator.py +++ b/src/ndi/gui/navigator.py @@ -295,6 +295,20 @@ def build_panes(self) -> None: def show(self) -> None: if self.figure is not None: self.figure.show() + self._check_for_updates_in_background() + + def _check_for_updates_in_background(self) -> None: + """The once-a-day update check (``ndi.update.checkOnStartup``). + + MATLAB runs it from ndi_Init; NDI-python has no such start-up hook, + so the navigator opening is where it happens. On a thread, so the + window never waits for GitHub; it only prints a line to the terminal. + """ + import threading + + from ..update import checkOnStartup + + threading.Thread(target=checkOnStartup, name="ndi-update-check", daemon=True).start() # ------------------------------------------------------------------ # window sizing diff --git a/src/ndi/gui/ndi_matlab_python_bridge.yaml b/src/ndi/gui/ndi_matlab_python_bridge.yaml index ea18e2d8..3d727b33 100644 --- a/src/ndi/gui/ndi_matlab_python_bridge.yaml +++ b/src/ndi/gui/ndi_matlab_python_bridge.yaml @@ -748,9 +748,20 @@ ported_late: - name: ndiPane matlab_path: "+ndi/+gui/+nav/ndiPane.m" - matlab_last_sync_hash: "8653c2c8" + matlab_last_sync_hash: "f9ced4bcd" python_path: "ndi/gui/nav/ndi_pane.py" - decision_log: "The NDI-branded pane. Ported in #88." + decision_log: > + The NDI-branded pane. Ported in #88. + 2026-10-18: synchronized with NDI-matlab f9ced4bcd (VH-Lab/NDI-matlab#1038, + branch claude/amazing-clarke-a34o5c): an "Update" button between the + broom and Prefs (pane 92 -> 156 pixels wide) that runs ndi.update.check + and, when an update can be installed, confirms and runs + ndi.update.install, defaulting to Cancel when the user's own code would + be touched. Ported with the decisions in a pure update_dialog() so they + are testable without Qt; the dialogs are QMessageBox (confirm blocks, as + uiconfirm does) and a wait cursor stands in for uiprogressdlg. MATLAB's + button also offers NDI-python updates from MATLAB; Python's checks + NDI-python only. - 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 06aef0de..09801318 100644 --- a/src/ndi/ndi_matlab_python_bridge.yaml +++ b/src/ndi/ndi_matlab_python_bridge.yaml @@ -30,7 +30,7 @@ functions: - name: version type: function matlab_path: "+ndi/version.m" - matlab_last_sync_hash: "8533e9f89" + matlab_last_sync_hash: "f9ced4bcd" python_path: "ndi/__init__.py" input_arguments: [] output_arguments: @@ -53,6 +53,10 @@ functions: NDI-python URL and `git describe --always --dirty`, falling back to the packaged __version__ where MATLAB falls back to the unexpanded '$Format:%H$' placeholder. Nothing to port. + 2026-10-18: NDI-matlab f9ced4bcd (VH-Lab/NDI-matlab#1038) makes a copy that is + not a git clone report its installed toolbox's release version instead + of the placeholder. Followed: a copy the NDI installer put in ~/.ndi + reports the commit its ~/.ndi/install.json records. - name: setup.lab type: function @@ -1155,7 +1159,7 @@ classes: - name: preferences type: class matlab_path: "+ndi/preferences.m" - matlab_last_sync_hash: "2941f93" + matlab_last_sync_hash: "f9ced4bcd" python_path: "ndi/preferences.py" python_class: "ndi_preferences" inherits: "" @@ -1197,6 +1201,12 @@ classes: MATLAB code this port does not have (LightsheetZarrManager, avi2mp4, prism2csv), and since this change a Python save keeps them in the shared file instead of dropping them. + 2026-10-18: NDI-matlab f9ced4bcd (VH-Lab/NDI-matlab#1038) registers + Update.CheckOnStartup, Update.CheckIntervalDays and + Update.MATLAB.LastCheckTime for ndi.update. The first two are ported + under the same keys, so one setting governs both languages. The third is + MATLAB's own: Python registers Update.Python.LastCheckTime instead, since + the two check different repositories and one must not hold the other off. properties: - name: Items diff --git a/src/ndi/preferences.py b/src/ndi/preferences.py index 8edaf38e..9dd855a0 100644 --- a/src/ndi/preferences.py +++ b/src/ndi/preferences.py @@ -240,6 +240,40 @@ def _register_defaults(self) -> None: "NDI_FROM_MATLAB=1, the console script itself drops MATLAB's " "library paths before loading anything.", ) + # Update.CheckOnStartup and Update.CheckIntervalDays are the same keys + # NDI-matlab registers, so one setting in the shared file governs + # both languages. The last-check time is per language (MATLAB's is + # Update.MATLAB.LastCheckTime): they check different repositories. + self._add_item( + "Update", + "", + "CheckOnStartup", + True, + "bool", + "Check for a newer NDI when it starts (NDI-python: when the " + "navigator opens), at most once every CheckIntervalDays days, and " + "print one line if there is one. NDI-python is compared with its " + "main branch on GitHub (see ndi.update.check).", + ) + self._add_item( + "Update", + "", + "CheckIntervalDays", + 1.0, + "float", + "Minimum number of days between the update checks made at startup.", + ) + self._add_item( + "Update", + "Python", + "LastCheckTime", + "", + "str", + "When NDI-python last checked for an update at startup " + "(yyyy-MM-ddTHH:mm:ss, UTC). Set automatically; clear it to check " + "at the next start. Kept apart from NDI-matlab's own time, because " + "both share this preferences file.", + ) def _add_item( self, diff --git a/src/ndi/update/__init__.py b/src/ndi/update/__init__.py new file mode 100644 index 00000000..8665e3ca --- /dev/null +++ b/src/ndi/update/__init__.py @@ -0,0 +1,32 @@ +"""ndi.update - check for, and install, a newer NDI-python. + +MATLAB counterpart: ``src/ndi/+ndi/+update/`` (NDI-matlab) + +NDI-python has no releases: every install follows the ``main`` branch on +GitHub, so an update is always "the commits main has that this copy lacks". + + >>> import ndi.update + >>> result = ndi.update.check() # report; changes nothing + >>> ndi.update.install() # update, after asking + +From a terminal: ``ndi update --check`` and ``ndi update``. + +NDI-matlab's ``ndi.update.check`` also checks an NDI-python that the NDI +installer put in ``~/.ndi`` (its ``checkPython``), reading the same +``~/.ndi/install.json`` receipt this package does. +""" + +from .check import check +from .check_on_startup import checkOnStartup +from .install import UpdateError, install +from .install_type import installType +from .modification_summary import modificationSummary + +__all__ = [ + "check", + "checkOnStartup", + "install", + "installType", + "modificationSummary", + "UpdateError", +] diff --git a/src/ndi/update/_git.py b/src/ndi/update/_git.py new file mode 100644 index 00000000..309c4e6f --- /dev/null +++ b/src/ndi/update/_git.py @@ -0,0 +1,36 @@ +"""ndi.update._git - run git in a repository (private helper). + +MATLAB counterpart: ``src/ndi/+ndi/+update/private/runGit.m`` +""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + + +def run_git(repo_dir: str | Path, *args: str, timeout: float = 120) -> tuple[int, str]: + """Run ``git -C REPO_DIR ARGS...`` and return ``(status, output)``. + + The output is stdout and stderr together with trailing whitespace removed. + Leading whitespace is kept, because it is meaningful in + ``git status --porcelain`` output. A missing git program, or a command + that runs past ``timeout`` seconds, gives a non-zero status rather than + an exception, as MATLAB's ``system`` does. + """ + try: + done = subprocess.run( + ["git", "-C", str(repo_dir), *args], + capture_output=True, + text=True, + timeout=timeout, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + return 1, str(exc) + return done.returncode, (done.stdout + done.stderr).rstrip() + + +def git_available() -> bool: + """True if the git program can be run.""" + status, _ = run_git(".", "--version") + return status == 0 diff --git a/src/ndi/update/_github.py b/src/ndi/update/_github.py new file mode 100644 index 00000000..149f76d1 --- /dev/null +++ b/src/ndi/update/_github.py @@ -0,0 +1,35 @@ +"""ndi.update._github - GET one GitHub REST API resource (private helper). + +MATLAB counterpart: ``src/ndi/+ndi/+update/private/githubApi.m`` +""" + +from __future__ import annotations + +import json +import os +import urllib.request +from typing import Any + +API_ROOT = "https://api.github.com/" + + +def github_api(api_path: str, timeout: float = 10) -> Any: + """Request ``https://api.github.com/API_PATH`` and decode its JSON. + + ``api_path`` may include a query string. If the environment variable + ``GITHUB_TOKEN`` is set it is sent with the request, which raises + GitHub's limit of 60 unauthenticated requests per hour. Network and HTTP + errors propagate (as ``urllib.error.URLError`` and its subclasses). + """ + headers = { + "Accept": "application/vnd.github+json", + "User-Agent": "NDI-python-update-check", + } + token = os.environ.get("GITHUB_TOKEN") + if token: + headers["Authorization"] = f"Bearer {token}" + request = urllib.request.Request(API_ROOT + api_path, headers=headers) + with urllib.request.urlopen( + request, timeout=timeout + ) as response: # noqa: S310 - fixed https host + return json.loads(response.read().decode("utf-8")) diff --git a/src/ndi/update/check.py b/src/ndi/update/check.py new file mode 100644 index 00000000..671ae235 --- /dev/null +++ b/src/ndi/update/check.py @@ -0,0 +1,352 @@ +"""ndi.update.check - is a newer NDI-python available? + +MATLAB counterpart: ``src/ndi/+ndi/+update/check.m`` (and, for the installer +case, ``checkPython.m``, which is how NDI-matlab checks this same install). + +NDI-python has no releases: every kind of install follows the ``main`` +branch on GitHub, so "newer" always means "main has commits this copy +lacks". How that is found depends on the install (see +:func:`ndi.update.installType`): + +* **git** -- ``git fetch origin main`` (which changes no files) and a count of + the commits on ``origin/main`` that ``HEAD`` lacks, as MATLAB does for its + own clone. The repositories ``ndi_install.py`` cloned into + ``~/.ndi/tools`` are compared with their upstream branches too, and the + user's own changes are recorded (``Modifications``). +* **installer** -- the commit in ``~/.ndi/install.json`` is compared with + main through GitHub's compare API. A receipt with no commit (the installer + could not reach GitHub's API) gives only its install time, and the commits + made on main since then are counted; an install older than the receipt + falls back to the time uv recorded it. +* **pip** -- nothing reliable records what was installed: Status "unknown". +""" + +from __future__ import annotations + +from collections.abc import Callable +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from pydantic import ConfigDict, validate_call + +from ._git import git_available, run_git +from ._github import github_api +from .install_type import installType, read_receipt + +__all__ = ["check", "REPOSITORY"] + +#: The GitHub repository installs are compared with. +REPOSITORY = "Waltham-Data-Science/NDI-python" + +INSTALL_HINT = "Run ndi.update.install() (or `ndi update` in a terminal) to update." + + +def _empty_result(install_type: str, repo_root: str) -> dict[str, Any]: + return { + "InstallType": install_type, + "Status": "unknown", + "UpdateAvailable": False, + "CanInstall": False, + "CurrentVersion": "", + "LatestVersion": "", + "InstalledAt": "", + "Message": "", + "Branch": "", + "CommitsBehind": None, + "CommitsAhead": None, + "HasModifications": False, + "Modifications": [], + "Dependencies": [], + "RepoRoot": repo_root, + } + + +@validate_call(config=ConfigDict(arbitrary_types_allowed=True)) +def check( + IncludeDependencies: bool = True, + Verbose: bool = True, + Timeout: float = 10, + RepoRoot: str = "", + NdiHome: str = "", + GitHubApi: Callable[[str, float], Any] | None = None, +) -> dict[str, Any]: + """Check whether a newer version of NDI-python is available. + + MATLAB:: + + RESULT = ndi.update.check() + RESULT = ndi.update.check(Name, Value) + + Args: + IncludeDependencies: Also check the repositories ``ndi_install.py`` + cloned into ``~/.ndi/tools`` (git installs only). + Verbose: Print the summary. + Timeout: Seconds to wait for GitHub. + RepoRoot: Check the NDI-python git clone in this folder instead of + the running copy. + NdiHome: Check the installer install under this folder (in place of + ``~/.ndi``) instead of the running copy. Used by tests. + GitHubApi: ``f(api_path, timeout)`` used to query GitHub in place of + the REST API. Used by tests. + + Returns: + A dict with MATLAB's field names: ``InstallType``, ``Status`` + (``"upToDate"``, ``"updateAvailable"``, ``"ahead"`` or ``"unknown"``), + ``UpdateAvailable``, ``CurrentVersion``, ``LatestVersion``, + ``Message``, ``Branch``, ``CommitsBehind``, ``CommitsAhead``, + ``HasModifications``, ``Modifications`` (one dict per repository + with ``Name``, ``Path``, ``ChangedFiles``, ``NewFiles`` and + ``CommitsAhead``), ``Dependencies`` (``Name``, ``Path``, ``Status``, + ``CommitsBehind``) and ``RepoRoot``. + + Python-specific Notes: + Also ``CanInstall`` (``ndi.update.install`` can update this kind of + install) and ``InstalledAt`` (the install time, when that is all an + installer receipt gives), the fields NDI-matlab's ``checkPython`` + 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. + """ + api = GitHubApi or github_api + if RepoRoot: + install_type, info = "git", {"RepoRoot": RepoRoot, "NdiHome": NdiHome or ""} + elif NdiHome: + install_type, info = "installer", {"RepoRoot": "", "NdiHome": NdiHome} + info["Receipt"] = read_receipt(NdiHome) + else: + install_type, info = installType() + + result = _empty_result(install_type, info.get("RepoRoot", "")) + if install_type == "git": + result = _check_git(result, info, IncludeDependencies) + elif install_type == "installer": + result = _check_installer(result, info, api, Timeout) + elif install_type == "pip": + result["Message"] = ( + "NDI-python was installed with pip, which records no version this " + "check can compare with main. Reinstall it the way you installed it " + "to update, or use the NDI installer (install/install-ndi.sh)." + ) + else: + result["Message"] = ( + "Could not tell how NDI-python was installed, so cannot check for updates." + ) + + if Verbose: + print(result["Message"]) + return result + + +# --------------------------------------------------------------------------- +# git installs +# --------------------------------------------------------------------------- + + +def _check_git(result: dict[str, Any], info: dict[str, Any], include_deps: bool) -> dict[str, Any]: + repo = info["RepoRoot"] + if not git_available(): + result["Message"] = ( + "This is a git install of NDI-python, but the git program was not " + "found, so cannot check for updates." + ) + return result + + result["CanInstall"] = True + _, result["Branch"] = run_git(repo, "rev-parse", "--abbrev-ref", "HEAD") + _, local_hash = run_git(repo, "rev-parse", "HEAD") + result["CurrentVersion"] = local_hash[:7] + + status, out = run_git(repo, "fetch", "--quiet", "origin", "main") + if status != 0: + result["Message"] = ( + "Could not reach the NDI-python repository on GitHub to check for " f"updates: {out}" + ) + return result + + _, remote_hash = run_git(repo, "rev-parse", "origin/main") + _, behind = run_git(repo, "rev-list", "--count", "HEAD..origin/main") + _, ahead = run_git(repo, "rev-list", "--count", "origin/main..HEAD") + result["LatestVersion"] = remote_hash[:7] + result["CommitsBehind"] = _to_int(behind) + result["CommitsAhead"] = _to_int(ahead) + + mods = _local_modifications("NDI-python", repo, result["CommitsAhead"]) + if include_deps: + tools = Path(info.get("NdiHome") or Path.home() / ".ndi") / "tools" + deps, dep_mods = _check_dependencies(tools) + result["Dependencies"] = deps + mods.extend(dep_mods) + result["Modifications"] = mods + result["HasModifications"] = bool(mods) + + deps_behind = [d["Name"] for d in result["Dependencies"] if d["Status"] == "behind"] + if (result["CommitsBehind"] or 0) > 0: + result["Status"] = "updateAvailable" + result["Message"] = ( + "An NDI-python update is available: the main branch on GitHub has " + f"{result['CommitsBehind']} commit(s) that you do not have (you have " + f"{result['CurrentVersion']}, latest is {result['LatestVersion']})." + ) + elif deps_behind: + result["Status"] = "updateAvailable" + result["Message"] = ( + "NDI-python is up to date with main, but updates are available for: " + + ", ".join(deps_behind) + + "." + ) + elif (result["CommitsAhead"] or 0) > 0: + result["Status"] = "ahead" + result["Message"] = ( + f"NDI-python has all commits from main, plus {result['CommitsAhead']} " + f"local commit(s) not on main (branch {result['Branch']})." + ) + else: + result["Status"] = "upToDate" + result["Message"] = ( + f"NDI-python is up to date with the main branch on GitHub " + f"({result['CurrentVersion']})." + ) + result["UpdateAvailable"] = result["Status"] == "updateAvailable" + + if result["UpdateAvailable"]: + notes = [] + if result["Branch"] != "main": + notes.append( + f"Note: you are on branch '{result['Branch']}', not main. Run " + f"'git checkout main' in {repo} before updating." + ) + if result["HasModifications"]: + names = ", ".join(m["Name"] for m in mods) + notes.append( + f"Note: you have changed code in {names}. ndi.update.install will " + "list the changes and ask before updating." + ) + notes.append(INSTALL_HINT) + result["Message"] = "\n".join([result["Message"], *notes]) + return result + + +def _check_dependencies(tools: Path) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + """The git clones ndi_install.py keeps in ~/.ndi/tools, against upstream.""" + deps: list[dict[str, Any]] = [] + mods: list[dict[str, Any]] = [] + 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} + 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") + mods.extend( + _local_modifications(path.name, path, _to_int(ahead) if ahead_status == 0 else 0) + ) + if fetched == 0 and counted == 0: + dep["CommitsBehind"] = _to_int(behind) + dep["Status"] = "behind" if (dep["CommitsBehind"] or 0) > 0 else "upToDate" + deps.append(dep) + return deps, mods + + +def _local_modifications( + name: str, repo: str | Path, commits_ahead: int | None +) -> list[dict[str, Any]]: + """A one-element list if REPO has edited, added or unpushed code, else [].""" + status, out = run_git(repo, "status", "--porcelain") + if status != 0: + return [] + lines = [line for line in out.splitlines() if len(line) > 3] + changed = [line[3:] for line in lines if not line.startswith("??")] + added = [line[3:] for line in lines if line.startswith("??")] + commits_ahead = commits_ahead or 0 + if not changed and not added and commits_ahead == 0: + return [] + return [ + { + "Name": name, + "Path": str(repo), + "ChangedFiles": changed, + "NewFiles": added, + "CommitsAhead": commits_ahead, + } + ] + + +# --------------------------------------------------------------------------- +# installer installs +# --------------------------------------------------------------------------- + + +def _check_installer( + result: dict[str, Any], info: dict[str, Any], api: Callable[[str, float], Any], timeout: float +) -> dict[str, Any]: + result["CanInstall"] = True + home = Path(info["NdiHome"]) + receipt = info.get("Receipt") or {} + repo = f"repos/{REPOSITORY}" + try: + head = api(f"{repo}/commits/main", timeout) + result["LatestVersion"] = str(head["sha"])[:7] + sha = receipt.get("commit") or "" + since_text = "" + if sha: + result["CurrentVersion"] = sha[:7] + compared = api(f"{repo}/compare/{sha}...main", timeout) + result["CommitsBehind"] = int(compared["ahead_by"]) + else: + installed_at = _install_time(receipt, home) + if installed_at is None: + result["Message"] = ( + "NDI-python was installed by the NDI installer, but which " + "version could not be determined. Run ndi.update.install() " + "to reinstall the latest." + ) + return result + stamp = installed_at.strftime("%Y-%m-%dT%H:%M:%SZ") + result["InstalledAt"] = stamp + result["CurrentVersion"] = "installed " + stamp[:10] + commits = api(f"{repo}/commits?sha=main&per_page=100&since={stamp}", timeout) + result["CommitsBehind"] = len(commits) + since_text = f" since it was installed on {stamp[:10]}" + except Exception as exc: # noqa: BLE001 - offline or rate-limited: say so, never raise + result["Status"] = "unknown" + result["Message"] = f"Could not reach GitHub to check NDI-python for updates: {exc}" + return result + + behind = result["CommitsBehind"] + if behind > 0: + count = "100 or more" if since_text and behind >= 100 else str(behind) + result["Status"] = "updateAvailable" + result["UpdateAvailable"] = True + result["Message"] = ( + f"An NDI-python update is available: main has {count} commit(s)" + f"{since_text} that the installed copy lacks (latest is " + f"{result['LatestVersion']}).\n{INSTALL_HINT}" + ) + else: + result["Status"] = "upToDate" + result["Message"] = f"NDI-python is up to date with main ({result['LatestVersion']})." + return result + + +def _install_time(receipt: dict[str, Any], home: Path) -> datetime | None: + """When the installer last installed NDI, from the receipt or uv's record.""" + stamp = receipt.get("installedAt") + if stamp: + try: + return datetime.strptime(stamp, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc) + except ValueError: + pass + # Installs made before the installer wrote receipts: uv rewrites this + # file each time it installs the 'ndi' tool. + uv_receipt = home / "venvs" / "ndi" / "uv-receipt.toml" + if uv_receipt.is_file(): + return datetime.fromtimestamp(uv_receipt.stat().st_mtime, tz=timezone.utc) + return None + + +def _to_int(text: str) -> int | None: + try: + return int(text.strip()) + except (ValueError, AttributeError): + return None diff --git a/src/ndi/update/check_on_startup.py b/src/ndi/update/check_on_startup.py new file mode 100644 index 00000000..6d863542 --- /dev/null +++ b/src/ndi/update/check_on_startup.py @@ -0,0 +1,84 @@ +"""ndi.update.check_on_startup - a quiet, at-most-daily update check. + +MATLAB counterpart: ``src/ndi/+ndi/+update/checkOnStartup.m`` +""" + +from __future__ import annotations + +import os +from datetime import datetime, timedelta, timezone + +from pydantic import validate_call + +__all__ = ["checkOnStartup", "LAST_CHECK_PREFERENCE"] + +#: Kept apart from NDI-matlab's Update.MATLAB.LastCheckTime: both languages +#: share NDI_Preferences.json, and one checking must not hold the other's +#: check off for a day, since they check different repositories. +LAST_CHECK_PREFERENCE = "Update.Python.LastCheckTime" + +_FORMAT = "%Y-%m-%dT%H:%M:%S" + + +@validate_call +def checkOnStartup() -> str: + """Check for an NDI-python update, at most once per interval. + + MATLAB:: + + ndi.update.checkOnStartup() + + If the preference ``Update.CheckOnStartup`` is true and at least + ``Update.CheckIntervalDays`` days have passed since the last check + (``Update.Python.LastCheckTime``), runs :func:`ndi.update.check` quietly + and prints one line if an update is available. It never raises: a failed + check (for example, no network) is ignored until the next interval. + + Skipped when the environment variable ``CI`` is set, so automated test + runs are not slowed down. + + To turn the check off:: + + ndi.preferences.set("Update.CheckOnStartup", False) + + Returns: + The line printed, or ``""``. + + Python-specific Notes: + NDI-python has no ``ndi_Init``; the navigator calls this when it + opens. Importing ``ndi`` never does, so a library import never goes + to the network. MATLAB returns nothing; the line is returned so a + caller (or a test) can see what was said. + """ + try: + from .. import preferences + from .check import check + + if os.environ.get("CI") or not preferences.get("Update.CheckOnStartup"): + return "" + + now = datetime.now(timezone.utc).replace(microsecond=0, tzinfo=None) + last_text = str(preferences.get(LAST_CHECK_PREFERENCE) or "") + interval = float(preferences.get("Update.CheckIntervalDays")) + if last_text: + try: + if now - datetime.strptime(last_text, _FORMAT) < timedelta(days=interval): + return "" + except ValueError: + pass # an unreadable time: check now and overwrite it + # Record the attempt first, so a failing check is not retried at + # every start. + preferences.set(LAST_CHECK_PREFERENCE, now.strftime(_FORMAT)) + + result = check(Verbose=False, Timeout=5) + if not result["UpdateAvailable"]: + return "" + first = result["Message"].splitlines()[0] + if result["CanInstall"]: + line = f"{first} Run ndi.update.install() (or `ndi update`) to update." + else: + line = first + print(line) + return line + except Exception: # noqa: BLE001 - never let an update check break startup + return "" diff --git a/src/ndi/update/install.py b/src/ndi/update/install.py new file mode 100644 index 00000000..f4ae3c48 --- /dev/null +++ b/src/ndi/update/install.py @@ -0,0 +1,266 @@ +"""ndi.update.install - update NDI-python to the newest version on main. + +MATLAB counterpart: ``src/ndi/+ndi/+update/install.m`` +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import sys +import tempfile +import urllib.request +from collections.abc import Callable +from pathlib import Path +from typing import Any + +from pydantic import ConfigDict, validate_call + +from ._git import run_git +from .check import check +from .modification_summary import modificationSummary + +__all__ = ["install", "UpdateError", "INSTALLER_URL"] + +#: Where the NDI installer scripts are published. +INSTALLER_URL = "https://raw.githubusercontent.com/Waltham-Data-Science/NDI-python/main/install" + + +class UpdateError(RuntimeError): + """An update that cannot go ahead. ``identifier`` is MATLAB's error id.""" + + def __init__(self, identifier: str, message: str): + super().__init__(message) + self.identifier = identifier + + +@validate_call(config=ConfigDict(arbitrary_types_allowed=True)) +def install( + Confirm: bool = True, + Force: bool = False, + AllowModified: bool = False, + Timeout: float = 10, + RepoRoot: str = "", + NdiHome: str = "", + GitHubApi: Callable[[str, float], Any] | None = None, + Ask: Callable[[str], str] | None = None, +) -> dict[str, Any]: + """Update NDI-python to the newest version on its main branch. + + MATLAB:: + + RESULT = ndi.update.install() + RESULT = ndi.update.install(Name, Value) + + 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 + ``~/.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 + (edited or added 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. + * **installer**: runs the NDI installer again (``install-ndi.sh --yes + --no-path``), which reinstalls NDI-python and all its dependencies + from main. On Windows the running ``ndi`` cannot be replaced while it + runs, so the installer is started in its own window and waits for + this process to exit. + + Afterwards NDI's in-memory caches are cleared (``ndi.fun.clearAllCaches``). + Restart Python (and the navigator) to use the new version. + + Args: + Confirm: Ask before installing. + Force: Install even if no update was found. + AllowModified: With ``Confirm=False``, update even if you have + changed code. Otherwise an :class:`UpdateError` listing the + changes is raised. Ignored when ``Confirm`` is true. + Timeout: Seconds to wait for GitHub. + RepoRoot: Update the git clone in this folder, not the running copy. + NdiHome: Treat the installer install under this folder as the one + to update (tests). + GitHubApi: Passed to :func:`ndi.update.check` (tests). + Ask: ``f(prompt) -> reply`` used for questions (default ``input``). + + Returns: + The dict :func:`ndi.update.check` returned before updating. + + Raises: + UpdateError: ``ndi:update:install:notOnMain``, + ``ndi:update:install:localModifications``, + ``ndi:update:install:noInstaller``, + ``ndi:update:install:pullFailed``, + ``ndi:update:install:installFailed`` 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``. + """ + ask = Ask or input + result = check( + Verbose=True, Timeout=Timeout, RepoRoot=RepoRoot, NdiHome=NdiHome, GitHubApi=GitHubApi + ) + if not result["UpdateAvailable"] and not Force: + return result + + kind = result["InstallType"] + if kind == "git": + _install_git(result, Confirm, AllowModified, ask) + elif kind == "installer": + _install_installer(result, Confirm, ask) + else: + raise UpdateError( + "ndi:update:install:cannotInstall", + "This copy of NDI-python was not installed by the NDI installer or " + "from a git clone, so ndi.update cannot update it. Update it the " + "way you installed it.", + ) + return result + + +def _install_git( + result: dict[str, Any], confirm: bool, allow_modified: bool, ask: Callable +) -> None: + repo = Path(result["RepoRoot"]) + if 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.", + ) + + if result["HasModifications"]: + summary = modificationSummary(result, MaxFiles=25) + if confirm: + print(summary) + if not _confirmed(ask, "Update anyway? Y/N [N]: ", default=False): + print("Update cancelled; your changes were not touched.") + return + elif not allow_modified: + raise UpdateError( + "ndi:update:install:localModifications", + f"{summary}\n\nUpdate cancelled. Commit, move or discard these " + "changes, or call ndi.update.install(Confirm=False, " + "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 not _confirmed(ask, prompt): + print("Update cancelled.") + return + + installer = repo / "ndi_install.py" + if 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") + stashed = False + if dirty.strip(): + status, out = run_git(repo, "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) + if stashed: + popped, pop_out = run_git(repo, "stash", "pop") + if popped != 0: + print( + "Warning: your stashed changes could not be put back cleanly " + f"({pop_out}). They are still in 'git stash list' in {repo}." + ) + if status != 0: + raise UpdateError("ndi:update:install:pullFailed", f"git pull failed: {out}") + + done = subprocess.run([sys.executable, str(installer), "--update"], cwd=str(repo)) + if done.returncode != 0: + raise UpdateError( + "ndi:update:install:installFailed", + f"ndi_install.py --update exited with status {done.returncode} (see above).", + ) + _clear_caches() + print("NDI-python update finished. Restart Python to use the new version.") + + +def _install_installer(result: dict[str, Any], confirm: bool, ask: Callable) -> None: + if confirm: + prompt = ( + f"This will run the NDI installer to update NDI-python " + f"({result['CurrentVersion']} -> {result['LatestVersion']}).\n" + "Continue? Y/N [Y]: " + ) + if not _confirmed(ask, prompt): + print("Update cancelled.") + return + + windows = os.name == "nt" + name = "install-ndi.ps1" if windows else "install-ndi.sh" + work = Path(tempfile.mkdtemp(prefix="ndi-update-")) + script = work / name + try: + urllib.request.urlretrieve( + f"{INSTALLER_URL}/{name}", script + ) # noqa: S310 - fixed https host + except Exception as exc: # noqa: BLE001 + raise UpdateError( + "ndi:update:install:installFailed", + f"Could not download the NDI installer from {INSTALLER_URL}/{name}: {exc}", + ) from exc + + if windows: + # The running ndi.exe / python.exe cannot be replaced while they run, so + # the installer waits in its own window for this process to exit. + quoted = str(script).replace("'", "''") # PowerShell single-quoted string + command = ( + f"Wait-Process -Id {os.getpid()} -ErrorAction SilentlyContinue; " + f"& '{quoted}' -Yes -NoPath; Read-Host 'Press Enter to close'" + ) + subprocess.Popen( + ["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", command], + creationflags=getattr(subprocess, "CREATE_NEW_CONSOLE", 0), + ) + print( + "The NDI installer has opened in a new window. Close NDI (this " + "program) to let it finish, then start NDI again." + ) + return + + try: + done = subprocess.run(["sh", str(script), "--yes", "--no-path"]) + finally: + shutil.rmtree(work, ignore_errors=True) + if done.returncode != 0: + raise UpdateError( + "ndi:update:install:installFailed", + f"The NDI installer exited with status {done.returncode} (see above).", + ) + _clear_caches() + print("NDI-python update finished. Restart NDI to use the new version.") + + +def _clear_caches() -> None: + """Drop in-memory copies the old version built; never fails the update.""" + try: + from ..fun.cache import clear_all_caches + + clear_all_caches() + except Exception as exc: # noqa: BLE001 + print(f"Warning: could not clear NDI caches after updating: {exc}") + + +def _confirmed(ask: Callable[[str], str], prompt: str, default: bool = True) -> bool: + """Ask a Y/N question; an empty reply gives DEFAULT.""" + reply = (ask(prompt) or "").strip().lower() + if not reply: + return default + return reply in ("y", "yes") diff --git a/src/ndi/update/install_type.py b/src/ndi/update/install_type.py new file mode 100644 index 00000000..97035fbb --- /dev/null +++ b/src/ndi/update/install_type.py @@ -0,0 +1,107 @@ +"""ndi.update.install_type - how the running copy of NDI-python was installed. + +MATLAB counterpart: ``src/ndi/+ndi/+update/installType.m`` + +MATLAB tells a git clone (``ndi_install``) from the packaged toolbox +(Add-On Explorer). NDI-python has three kinds of install, so it answers +with one of these instead: + +``"installer"`` + The NDI installer (``install/install-ndi.sh`` / ``install-ndi.ps1``, + which NDI-matlab's ``ndi.python.install`` also runs) put it in + ``~/.ndi/venvs``. Its ``~/.ndi/install.json`` records the commit. +``"git"`` + A clone of NDI-python, installed with ``pip install -e .`` or + ``python ndi_install.py``. +``"pip"`` + Installed by pip into some other environment, from an archive or wheel. +``"unknown"`` + None of the above could be determined. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from pydantic import validate_call + +__all__ = ["installType", "ndi_home", "read_receipt"] + +#: The receipt the NDI installer writes beside bin/, python/ and venvs/. +RECEIPT_NAME = "install.json" + + +def ndi_home() -> Path: + """``~/.ndi``, the folder the NDI installer installs under. + + MATLAB counterpart: ``ndi.python.ndiHome``. + """ + return Path.home() / ".ndi" + + +def read_receipt(home: str | Path | None = None) -> dict[str, Any] | None: + """The NDI installer's ``install.json`` as a dict, or None. + + A missing or unreadable receipt is None: it is treated as no receipt. + """ + path = Path(home or ndi_home()) / RECEIPT_NAME + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return None + return data if isinstance(data, dict) else None + + +@validate_call +def installType(ndiHome: str = "") -> tuple[str, dict[str, Any]]: + """Return how the running copy of NDI-python was installed. + + MATLAB:: + + [TYPE, INFO] = ndi.update.installType() + + Returns: + ``(type, info)``. ``type`` is ``"installer"``, ``"git"``, ``"pip"`` or + ``"unknown"`` (see the module docstring). ``info`` has: + + - ``SourceDir``: the folder holding the running ``ndi`` package + - ``RepoRoot``: root of the git clone (``""`` unless ``"git"``) + - ``NdiHome``: the ``~/.ndi`` folder that was examined + - ``Receipt``: the installer's ``install.json`` (a dict) or None + + Python-specific Notes: + ``ndiHome`` replaces ``~/.ndi`` (tests use it). MATLAB's + ``ToolboxVersion`` and ``BothInstalled`` have no counterpart: there + is no packaged NDI-python toolbox. + """ + import ndi + + source_dir = Path(ndi.__file__).resolve().parent + home = Path(ndiHome) if ndiHome else ndi_home() + info: dict[str, Any] = { + "SourceDir": str(source_dir), + "RepoRoot": "", + "NdiHome": str(home), + "Receipt": None, + } + + # src/ndi -> src -> repository root + repo_root = source_dir.parent.parent + if (repo_root / ".git").exists() and (repo_root / "pyproject.toml").is_file(): + info["RepoRoot"] = str(repo_root) + return "git", info + + venvs = (home / "venvs").resolve() + if venvs in source_dir.parents: + info["Receipt"] = read_receipt(home) + return "installer", info + + try: + from importlib.metadata import distribution + + distribution("ndi") + except Exception: # noqa: BLE001 - no metadata at all + return "unknown", info + return "pip", info diff --git a/src/ndi/update/modification_summary.py b/src/ndi/update/modification_summary.py new file mode 100644 index 00000000..6e61788e --- /dev/null +++ b/src/ndi/update/modification_summary.py @@ -0,0 +1,60 @@ +"""ndi.update.modification_summary - describe the user's own code changes. + +MATLAB counterpart: ``src/ndi/+ndi/+update/modificationSummary.m`` +""" + +from __future__ import annotations + +from typing import Any + +from pydantic import Field, validate_call + +__all__ = ["modificationSummary"] + + +@validate_call +def modificationSummary(result: dict[str, Any], MaxFiles: int = Field(10, gt=0)) -> str: + """Describe the user's code changes that an update affects. + + MATLAB:: + + TXT = ndi.update.modificationSummary(RESULT) + TXT = ndi.update.modificationSummary(RESULT, MaxFiles=N) + + Given RESULT from :func:`ndi.update.check`, returns a human-readable + string listing, for each repository with modifications, the changed + files, the new files and the number of local commits, followed by what + updating does to each kind of change. Returns ``""`` if there are none. + At most ``MaxFiles`` files are listed per repository; the rest are + counted. + """ + mods = result.get("Modifications") or [] + if not mods: + return "" + + parts = ["You have changed code that the update will touch:"] + for m in mods: + counts = [] + if m["ChangedFiles"]: + counts.append(f"{len(m['ChangedFiles'])} changed file(s)") + if m["NewFiles"]: + counts.append(f"{len(m['NewFiles'])} new file(s)") + if m["CommitsAhead"] > 0: + counts.append(f"{m['CommitsAhead']} local commit(s)") + parts.append(f"\n{m['Name']} ({m['Path']}): " + ", ".join(counts)) + + files = [f" changed: {f}" for f in m["ChangedFiles"]] + [ + f" new: {f}" for f in m["NewFiles"] + ] + parts.extend(files[:MaxFiles]) + if len(files) > MaxFiles: + parts.append(f" ... and {len(files) - MaxFiles} more") + + parts.append( + "\nIf you update: changed files are stashed and put back after the " + "update (if they no longer apply cleanly, 'git stash list' in that " + "folder still has them), new files are left in place, and local " + "commits are merged with the update, which can stop with a merge " + "conflict." + ) + return "\n".join(parts) diff --git a/src/ndi/update/ndi_matlab_python_bridge.yaml b/src/ndi/update/ndi_matlab_python_bridge.yaml new file mode 100644 index 00000000..ba8d153e --- /dev/null +++ b/src/ndi/update/ndi_matlab_python_bridge.yaml @@ -0,0 +1,229 @@ +# ndi_matlab_python_bridge.yaml — src/ndi/update/ +# The Primary Contract for the ndi.update namespace. +# +# Every MATLAB file here is on VH-Lab/NDI-matlab branch claude/amazing-clarke-a34o5c +# (VH-Lab/NDI-matlab#1038), not yet on main, so each entry carries +# matlab_branch (section 8 of docs/developer_notes/ndi_matlab_python_bridge.yaml). +# Remove the markers once that branch merges. +# +# What the two halves have in common: both check their OWN repository's main +# branch (NDI-matlab: also its releases, for the toolbox), report the same +# result fields, refuse the same unsafe updates (not on main; the user's own +# changes without consent), and read the same ~/.ndi/install.json receipt that +# install/install-ndi.sh and install-ndi.ps1 now write. + +project_metadata: + bridge_version: "1.1" + naming_policy: "Strict MATLAB Mirror" + indexing_policy: "Semantic Parity (1-based for user concepts, 0-based for internal data)" + +functions: + + - name: check + type: function + matlab_path: "+ndi/+update/check.m" + matlab_last_sync_hash: "f9ced4bcd" + matlab_branch: "claude/amazing-clarke-a34o5c" + python_path: "ndi/update/check.py" + input_arguments: + - name: IncludeDependencies + type_matlab: "logical" + type_python: "bool" + - name: Verbose + type_matlab: "logical" + type_python: "bool" + - name: Timeout + type_matlab: "double" + type_python: "float" + - name: RepoRoot + type_matlab: "string" + type_python: "str" + - name: IncludePython + type_matlab: "logical" + decision_log: "No counterpart: NDI-python checking NDI-python is the whole function." + - 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)." + - name: GitHubApi + type_python: "Callable[[str, float], Any] | None" + decision_log: "Python only, for tests (MATLAB's checkPython has the same option)." + output_arguments: + - name: result + type_matlab: "struct" + type_python: "dict" + decision_log: > + Ported 2026-10-18 against NDI-matlab f9ced4bcd. Same field names in a dict. + Git installs: the same git logic (fetch origin main, commits behind and + ahead, the user's changed/new files and local commits), with the + dependency clones taken from ~/.ndi/tools, where ndi_install.py puts + them, instead of from requirements.txt siblings. Toolbox installs have + no counterpart (NDI-python has no releases); in their place the + installer install is compared with main through the commit in + ~/.ndi/install.json, falling back to commits since the install time -- + the same three-step lookup as MATLAB's checkPython, which is how + NDI-matlab checks this same install. Adds CanInstall and InstalledAt + (both from checkPython's result) and drops ReleaseUrl, MltbxUrl, + BothInstalled and Python. NaN counts are None. + + - name: install + type: function + matlab_path: "+ndi/+update/install.m" + matlab_last_sync_hash: "f9ced4bcd" + matlab_branch: "claude/amazing-clarke-a34o5c" + python_path: "ndi/update/install.py" + input_arguments: + - name: Confirm + type_python: "bool" + - name: Force + type_python: "bool" + - name: AllowModified + type_python: "bool" + - name: Timeout + type_python: "float" + - name: RepoRoot + type_python: "str" + - name: Dependencies + type_matlab: "double (1 or 2)" + decision_log: "No counterpart: NDI-python's dependencies are fixed by pyproject.toml." + - name: IncludePython + type_matlab: "logical" + decision_log: "No counterpart (see check)." + output_arguments: + - name: result + type_python: "dict" + decision_log: > + Ported 2026-10-18 against NDI-matlab f9ced4bcd. Same guards in the same order + and with the same error identifiers (UpdateError.identifier): not on + main, the user's modifications (listed, default No; with Confirm=False + an error unless AllowModified), then no installer. Git: pull --ff-only + origin main with changed files stashed and restored (MATLAB's + ndi_install stashes without restoring; ndi_install.py restores, and + this follows it), then python ndi_install.py --update. Installer: + re-runs install-ndi.sh --yes --no-path, or on Windows starts + install-ndi.ps1 in its own window, waiting for this process to exit, + because a running ndi.exe cannot be replaced. Clears caches afterwards, + as MATLAB does. Ask stands in for MATLAB's input (tests). + + - name: installType + type: function + matlab_path: "+ndi/+update/installType.m" + matlab_last_sync_hash: "f9ced4bcd" + matlab_branch: "claude/amazing-clarke-a34o5c" + python_path: "ndi/update/install_type.py" + output_arguments: + - name: type + type_matlab: "string" + type_python: "str" + - name: info + type_matlab: "struct" + type_python: "dict" + decision_log: > + Ported 2026-10-18 against NDI-matlab f9ced4bcd. MATLAB answers git / toolbox / + unknown; NDI-python's installs are installer / git / pip / unknown. info + has SourceDir and RepoRoot as in MATLAB, plus NdiHome and Receipt (the + parsed install.json); ToolboxVersion and BothInstalled have no + counterpart without a packaged toolbox. + + - name: modificationSummary + type: function + matlab_path: "+ndi/+update/modificationSummary.m" + matlab_last_sync_hash: "f9ced4bcd" + matlab_branch: "claude/amazing-clarke-a34o5c" + python_path: "ndi/update/modification_summary.py" + input_arguments: + - name: result + type_python: "dict" + - name: MaxFiles + type_python: "int" + output_arguments: + - name: txt + type_python: "str" + decision_log: > + Ported 2026-10-18 against NDI-matlab f9ced4bcd. Same layout. The closing + sentence says changed files are put back after the update, because + Python's git update restores the stash (see install). + + - name: checkOnStartup + type: function + matlab_path: "+ndi/+update/checkOnStartup.m" + matlab_last_sync_hash: "f9ced4bcd" + matlab_branch: "claude/amazing-clarke-a34o5c" + python_path: "ndi/update/check_on_startup.py" + output_arguments: + - name: line + type_matlab: "(none; prints)" + type_python: "str" + decision_log: > + Ported 2026-10-18 against NDI-matlab f9ced4bcd. Shares Update.CheckOnStartup + and Update.CheckIntervalDays with MATLAB through NDI_Preferences.json, + but keeps its own Update.Python.LastCheckTime (MATLAB's is + Update.MATLAB.LastCheckTime), since one language checking must not hold + 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. + + - name: checkPython + type: function + matlab_path: "+ndi/+update/checkPython.m" + matlab_last_sync_hash: "e2a3d8e57" + matlab_branch: "claude/amazing-clarke-a34o5c" + python_path: "ndi/update/check.py" + status: ported_differently + decision_log: > + MATLAB's check of an installed NDI-python, from NDI-matlab. Here that is + ndi.update.check itself (its installer branch), using the same receipt, + the same fallback to the install time (the receipt's installedAt, then + uv's file time; NDI-matlab e2a3d8e57) and the same result fields. + + - name: compareVersions + type: function + matlab_path: "+ndi/+update/compareVersions.m" + matlab_last_sync_hash: "f9ced4bcd" + matlab_branch: "claude/amazing-clarke-a34o5c" + status: matlab_only + decision_log: > + Compares release numbers for NDI-matlab's packaged toolbox. NDI-python + has no releases -- every install follows main and is compared by commit + -- so there is nothing to compare. + + - name: latestRelease + type: function + matlab_path: "+ndi/+update/latestRelease.m" + matlab_last_sync_hash: "f9ced4bcd" + matlab_branch: "claude/amazing-clarke-a34o5c" + status: matlab_only + decision_log: > + Fetches NDI-matlab's latest GitHub release for its toolbox. NDI-python + has no releases (see compareVersions). + + - name: toolboxIdentifier + type: function + matlab_path: "+ndi/+update/toolboxIdentifier.m" + matlab_last_sync_hash: "f9ced4bcd" + matlab_branch: "claude/amazing-clarke-a34o5c" + status: matlab_only + decision_log: > + The MATLAB Add-On identifier of the NDI-matlab toolbox. A MATLAB + packaging concept; NDI-python has no counterpart. + + - name: runGit + type: function + matlab_path: "+ndi/+update/private/runGit.m" + matlab_last_sync_hash: "f9ced4bcd" + matlab_branch: "claude/amazing-clarke-a34o5c" + python_path: "ndi/update/_git.py" + status: ported_differently + decision_log: > + Private helper; here run_git in the private module _git, taking the + git arguments as a list (subprocess, no shell) rather than one string. + + - name: githubApi + type: function + matlab_path: "+ndi/+update/private/githubApi.m" + matlab_last_sync_hash: "f9ced4bcd" + matlab_branch: "claude/amazing-clarke-a34o5c" + python_path: "ndi/update/_github.py" + status: ported_differently + decision_log: > + Private helper; here github_api in the private module _github, on + urllib.request instead of webread. diff --git a/tests/test_gui_nav_pane.py b/tests/test_gui_nav_pane.py index cb8f0c50..31228b1e 100644 --- a/tests/test_gui_nav_pane.py +++ b/tests/test_gui_nav_pane.py @@ -25,7 +25,14 @@ import pytest from ndi.gui.cloud_colors import CloudColors, cloud_colors, cloudColors, rgb_to_hex -from ndi.gui.nav.ndi_pane import BROOM, BROOM_WIDTH, BUTTON_SPACING, PREFS_WIDTH, NdiPane +from ndi.gui.nav.ndi_pane import ( + BROOM, + BROOM_WIDTH, + BUTTON_SPACING, + PREFS_WIDTH, + UPDATE_WIDTH, + NdiPane, +) from ndi.gui.nav.pane import HEADER_HEIGHT, TRIANGLE_DOWN, TRIANGLE_RIGHT, NavPane @@ -261,8 +268,11 @@ def test_is_the_uncollapsible_header_pane(self): assert p.current_height() == HEADER_HEIGHT def test_right_width_is_the_sum_of_its_buttons(self): - assert NdiPane().right_width() == BROOM_WIDTH + BUTTON_SPACING + PREFS_WIDTH - assert NdiPane().right_width() == 92 # the number MATLAB hardcodes + assert ( + NdiPane().right_width() + == BROOM_WIDTH + BUTTON_SPACING + UPDATE_WIDTH + BUTTON_SPACING + PREFS_WIDTH + ) + assert NdiPane().right_width() == 156 # the number MATLAB hardcodes def test_broom_is_a_single_code_point(self): """MATLAB needs a UTF-16 surrogate pair for U+1F9F9; Python does not, diff --git a/tests/test_update_check_git.py b/tests/test_update_check_git.py new file mode 100644 index 00000000..896ee3b3 --- /dev/null +++ b/tests/test_update_check_git.py @@ -0,0 +1,251 @@ +"""ndi.update.check and ndi.update.install on git clones. + +MATLAB counterpart: tests/+ndi/+unittest/+update/TestCheckGitClone.m + +Hermetic: each test builds bare repositories that stand in for GitHub, plus +an NDI-python clone and an ``~/.ndi/tools`` dependency clone of them, in a +temp folder, and points check/install at them with RepoRoot and NdiHome. No +network. The fake NDI-python has no ndi_install.py, so install can never +run the real installer. +""" + +from __future__ import annotations + +import shutil +import subprocess +from pathlib import Path + +import pytest + +from ndi.update import UpdateError, check, install, modificationSummary + +pytestmark = pytest.mark.skipif(shutil.which("git") is None, reason="needs the git program") + +IDENTITY = [ + "-c", + "user.name=ndiTest", + "-c", + "user.email=ndi@example.com", + "-c", + "commit.gpgsign=false", +] + + +def git(repo: Path, *args: str) -> str: + done = subprocess.run( + ["git", *IDENTITY, "-C", str(repo), *args], capture_output=True, text=True + ) + assert done.returncode == 0, f"git {' '.join(args)} failed: {done.stdout}{done.stderr}" + return done.stdout + + +def commit_all(repo: Path, message: str) -> None: + git(repo, "add", "-A") + git(repo, "commit", "-q", "-m", message) + + +class Clones: + """Upstream seeds, bare 'GitHub' remotes, and the installed clones.""" + + def __init__(self, root: Path): + self.root = root + self.seeds = root / "seed" + self.home = root / "home" / ".ndi" + self.ndi = root / "work" / "NDI-python" + remotes = root / "remotes" + installed = { + "NDI-python": self.ndi, + "vhlab-toolbox-python": self.home / "tools" / "vhlab-toolbox-python", + } + for name, target in installed.items(): + seed = self.seeds / name + seed.mkdir(parents=True) + git(seed, "init", "-q", "-b", "main") + (seed / "README.md").write_text("hello") + commit_all(seed, "initial") + bare = remotes / f"{name}.git" + git(root, "clone", "-q", "--bare", str(seed), str(bare)) + git(seed, "remote", "add", "origin", str(bare)) + target.parent.mkdir(parents=True, exist_ok=True) + git(root, "clone", "-q", str(bare), str(target)) + self.dep = installed["vhlab-toolbox-python"] + + def push_upstream(self, name: str, file: str = "README.md", text: str = "more") -> None: + seed = self.seeds / name + path = seed / file + path.write_text(path.read_text() + "\n" + text if path.exists() else text) + commit_all(seed, "upstream change") + git(seed, "push", "-q", "origin", "main") + + +@pytest.fixture +def clones(tmp_path: Path) -> Clones: + return Clones(tmp_path) + + +def run_check(clones: Clones, **kw) -> dict: + return check(RepoRoot=str(clones.ndi), NdiHome=str(clones.home), Verbose=False, **kw) + + +def run_install(clones: Clones, **kw) -> dict: + return install(RepoRoot=str(clones.ndi), NdiHome=str(clones.home), Confirm=False, **kw) + + +def test_up_to_date(clones): + r = run_check(clones) + assert r["InstallType"] == "git" + assert r["Status"] == "upToDate" + assert r["UpdateAvailable"] is False + assert r["CommitsBehind"] == 0 + assert r["Branch"] == "main" + assert r["HasModifications"] is False + assert r["CurrentVersion"] == r["LatestVersion"] + assert [(d["Name"], d["Status"]) for d in r["Dependencies"]] == [ + ("vhlab-toolbox-python", "upToDate") + ] + + +def test_behind_main(clones): + clones.push_upstream("NDI-python") + r = run_check(clones) + assert r["Status"] == "updateAvailable" + assert r["UpdateAvailable"] is True + assert r["CommitsBehind"] == 1 + assert r["CurrentVersion"] != r["LatestVersion"] + assert "1 commit(s)" in r["Message"] + assert "ndi.update.install()" in r["Message"] + + +def test_dependency_behind(clones): + clones.push_upstream("vhlab-toolbox-python") + r = run_check(clones) + assert r["CommitsBehind"] == 0 + assert r["Status"] == "updateAvailable" + assert r["Dependencies"][0]["Status"] == "behind" + assert r["Dependencies"][0]["CommitsBehind"] == 1 + assert "vhlab-toolbox-python" in r["Message"] + + +def test_dependencies_can_be_skipped(clones): + clones.push_upstream("vhlab-toolbox-python") + r = run_check(clones, IncludeDependencies=False) + assert r["Dependencies"] == [] + assert r["Status"] == "upToDate" + + +def test_local_commits_are_ahead(clones): + (clones.ndi / "local.py").write_text("x = 1\n") + commit_all(clones.ndi, "local work") + r = run_check(clones) + assert r["Status"] == "ahead" + assert r["UpdateAvailable"] is False + assert r["CommitsAhead"] == 1 + assert r["HasModifications"] is True + assert r["Modifications"][0]["CommitsAhead"] == 1 + + +def test_modifications_are_listed(clones): + clones.push_upstream("NDI-python") + (clones.ndi / "README.md").write_text("my edit") + (clones.ndi / "mine.py").write_text("y = 2\n") + (clones.dep / "extra.py").write_text("z = 3\n") + commit_all(clones.dep, "local dependency commit") + + r = run_check(clones) + assert r["HasModifications"] is True + by_name = {m["Name"]: m for m in r["Modifications"]} + assert set(by_name) == {"NDI-python", "vhlab-toolbox-python"} + assert by_name["NDI-python"]["ChangedFiles"] == ["README.md"] + assert by_name["NDI-python"]["NewFiles"] == ["mine.py"] + assert by_name["NDI-python"]["CommitsAhead"] == 0 + assert by_name["vhlab-toolbox-python"]["CommitsAhead"] == 1 + assert "changed code in" in r["Message"] + + txt = modificationSummary(r) + assert "changed: README.md" in txt + assert "new: mine.py" in txt + assert "1 local commit(s)" in txt + + +def test_install_with_no_update_does_nothing(clones): + r = run_install(clones) + assert r["UpdateAvailable"] is False + + +def test_install_refuses_modified_code(clones): + clones.push_upstream("NDI-python") + (clones.ndi / "README.md").write_text("my edit") + with pytest.raises(UpdateError) as err: + run_install(clones) + assert err.value.identifier == "ndi:update:install:localModifications" + assert (clones.ndi / "README.md").read_text() == "my edit" + + +def test_install_allow_modified_goes_on(clones): + # Past the modification guard, the next step needs ndi_install.py, + # which the fake clone lacks. + clones.push_upstream("NDI-python") + (clones.ndi / "mine.py").write_text("y = 2\n") + with pytest.raises(UpdateError) as err: + run_install(clones, AllowModified=True) + assert err.value.identifier == "ndi:update:install:noInstaller" + + +def test_install_refuses_other_branch(clones): + clones.push_upstream("NDI-python") + git(clones.ndi, "checkout", "-q", "-b", "myFeature") + r = run_check(clones) + assert r["Branch"] == "myFeature" + assert "git checkout main" in r["Message"] + with pytest.raises(UpdateError) as err: + run_install(clones) + assert err.value.identifier == "ndi:update:install:notOnMain" + + +def test_confirm_defaults_to_no_when_code_is_modified(clones): + clones.push_upstream("NDI-python") + (clones.ndi / "README.md").write_text("my edit") + asked = [] + install( + RepoRoot=str(clones.ndi), + NdiHome=str(clones.home), + Ask=lambda prompt: asked.append(prompt) or "", + ) + assert asked == ["Update anyway? Y/N [N]: "] + assert (clones.ndi / "README.md").read_text() == "my edit" + + +def test_install_pulls_and_runs_ndi_install(clones): + # A stand-in ndi_install.py records that it ran with --update. + 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(' '.join(sys.argv[1:]))\n" + ) + commit_all(seed, "add installer") + git(seed, "push", "-q", "origin", "main") + git(clones.ndi, "pull", "-q", "--ff-only") + clones.push_upstream("NDI-python", text="the update") + + r = run_install(clones) + assert r["CommitsBehind"] == 1 + assert "the update" in (clones.ndi / "README.md").read_text() # pulled + assert marker.read_text() == "--update" + assert run_check(clones)["Status"] == "upToDate" + + +def test_changed_files_survive_an_update(clones): + # A tracked file the user edited is stashed for the pull and put back. + seed = clones.seeds / "NDI-python" + (seed / "ndi_install.py").write_text("print('stand-in installer')\n") + (seed / "notes.txt").write_text("original\n") + commit_all(seed, "add files") + git(seed, "push", "-q", "origin", "main") + git(clones.ndi, "pull", "-q", "--ff-only") + clones.push_upstream("NDI-python", text="the update") + (clones.ndi / "notes.txt").write_text("my notes\n") + + run_install(clones, AllowModified=True) + assert (clones.ndi / "notes.txt").read_text() == "my notes\n" + assert "the update" in (clones.ndi / "README.md").read_text() diff --git a/tests/test_update_installer.py b/tests/test_update_installer.py new file mode 100644 index 00000000..befba8ea --- /dev/null +++ b/tests/test_update_installer.py @@ -0,0 +1,126 @@ +"""ndi.update.check for a copy the NDI installer put in ~/.ndi. + +MATLAB counterpart: tests/+ndi/+unittest/+update/TestCheckPython.m, which +checks this same install from NDI-matlab (ndi.update.checkPython). + +A fake ~/.ndi (the NdiHome option) and a fake GitHub API (the GitHubApi +option): no network, and no real installer install is needed. +""" + +from __future__ import annotations + +import json +import os +import time +from pathlib import Path + +import pytest + +from ndi.update import check +from ndi.update.install_type import read_receipt + +INSTALLED = "a" * 40 +LATEST = "b" * 40 +REPO = "repos/Waltham-Data-Science/NDI-python" + + +def fake_api(installed: str, ahead_by: int = 0, commits_since: int = 0): + """Stand-in for GitHub: answers the requests check makes, and logs them.""" + calls: list[str] = [] + + def api(path: str, timeout: float): + calls.append(path) + if path == f"{REPO}/commits/main": + return {"sha": LATEST} + if path == f"{REPO}/compare/{installed}...main": + return {"ahead_by": ahead_by, "behind_by": 0} + if path.startswith(f"{REPO}/commits?sha=main&per_page=100&since="): + return [{"sha": "c"}] * commits_since + raise AssertionError(f"unexpected GitHub request: {path}") + + api.calls = calls + return api + + +def write_receipt(home: Path, **fields) -> None: + home.mkdir(parents=True, exist_ok=True) + (home / "install.json").write_text(json.dumps(fields), encoding="utf-8") + + +def run(home: Path, api) -> dict: + return check(NdiHome=str(home), GitHubApi=api, Verbose=False) + + +def test_receipt_behind_main(tmp_path): + write_receipt(tmp_path, commit=INSTALLED, installedAt="2026-10-01T00:00:00Z") + r = run(tmp_path, fake_api(INSTALLED, ahead_by=3)) + assert r["InstallType"] == "installer" + assert r["CanInstall"] is True + assert r["Status"] == "updateAvailable" + assert r["UpdateAvailable"] is True + assert r["CommitsBehind"] == 3 + assert r["CurrentVersion"] == "aaaaaaa" + assert r["LatestVersion"] == "bbbbbbb" + assert "ndi.update.install()" in r["Message"] + + +def test_receipt_up_to_date(tmp_path): + write_receipt(tmp_path, commit=LATEST) + r = run(tmp_path, fake_api(LATEST, ahead_by=0)) + assert r["Status"] == "upToDate" + assert r["UpdateAvailable"] is False + + +def test_receipt_without_commit_counts_commits_since_install(tmp_path): + # The installer could not reach GitHub's API: only the time is known. + write_receipt(tmp_path, commit=None, installedAt="2026-10-01T12:00:00Z") + api = fake_api("", commits_since=2) + r = run(tmp_path, api) + assert r["Status"] == "updateAvailable" + assert r["CommitsBehind"] == 2 + assert r["InstalledAt"] == "2026-10-01T12:00:00Z" + assert r["CurrentVersion"] == "installed 2026-10-01" + assert "since it was installed on 2026-10-01" in r["Message"] + assert api.calls[-1].endswith("since=2026-10-01T12:00:00Z") + + +def test_install_older_than_receipts_uses_uv_time(tmp_path): + uv_receipt = tmp_path / "venvs" / "ndi" / "uv-receipt.toml" + uv_receipt.parent.mkdir(parents=True) + uv_receipt.write_text("[tool]\n") + stamp = time.mktime((2026, 9, 1, 0, 0, 0, 0, 0, -1)) + os.utime(uv_receipt, (stamp, stamp)) + r = run(tmp_path, fake_api("", commits_since=100)) + assert r["CommitsBehind"] == 100 + assert r["InstalledAt"].startswith("2026-0") + assert "100 or more commit(s)" in r["Message"] + + +def test_no_version_information_is_unknown(tmp_path): + r = run(tmp_path, fake_api("")) + assert r["Status"] == "unknown" + assert r["UpdateAvailable"] is False + + +def test_github_failure_is_unknown_not_an_error(tmp_path): + write_receipt(tmp_path, commit=INSTALLED) + + def offline(path, timeout): + raise OSError("no network") + + r = run(tmp_path, offline) + assert r["Status"] == "unknown" + assert r["UpdateAvailable"] is False + assert "no network" in r["Message"] + + +@pytest.mark.parametrize("text", ["", "not json", "[1, 2]"]) +def test_unreadable_receipt_is_no_receipt(tmp_path, text): + (tmp_path / "install.json").write_text(text, encoding="utf-8") + assert read_receipt(tmp_path) is None + + +def test_receipt_with_byte_order_mark_is_rejected_not_crashed(tmp_path): + # The installer writes UTF-8 without a BOM; a BOM is unreadable, not fatal. + (tmp_path / "install.json").write_bytes(b"\xef\xbb\xbf" + json.dumps({"commit": "x"}).encode()) + assert read_receipt(tmp_path) is None diff --git a/tests/test_update_misc.py b/tests/test_update_misc.py new file mode 100644 index 00000000..7aeb34db --- /dev/null +++ b/tests/test_update_misc.py @@ -0,0 +1,249 @@ +"""ndi.update: modificationSummary, checkOnStartup, installType, the CLI and +the navigator's Update-button decisions (update_dialog), all without Qt or +network. + +MATLAB counterparts: TestModificationSummary.m, TestInstallType.m and +TestNavigatorLayout/testNdiPaneHasUpdateButton in NDI-matlab's +tests/+ndi/+unittest. +""" + +from __future__ import annotations + +import json +import sys + +import pytest + +import ndi +import ndi.preferences +import ndi.update +from ndi.gui.nav.ndi_pane import ( + BROOM_WIDTH, + BUTTON_SPACING, + PREFS_WIDTH, + UPDATE_WIDTH, + NdiPane, + update_dialog, +) +from ndi.update import checkOnStartup, installType, modificationSummary + +check_module = sys.modules["ndi.update.check"] + + +# ---- modificationSummary --------------------------------------------------- + + +def mod(name="NDI-python", changed=(), new=(), ahead=0): + return { + "Name": name, + "Path": f"/x/{name}", + "ChangedFiles": list(changed), + "NewFiles": list(new), + "CommitsAhead": ahead, + } + + +def test_no_modifications_gives_empty(): + assert modificationSummary({"Modifications": []}) == "" + + +def test_summary_lists_files_and_commits(): + txt = modificationSummary( + {"Modifications": [mod(changed=["src/a.py", "src/b.py"], new=["mine.py"], ahead=1)]} + ) + assert "NDI-python (/x/NDI-python)" in txt + assert "2 changed file(s)" in txt + assert "1 new file(s)" in txt + assert "1 local commit(s)" in txt + assert "changed: src/a.py" in txt + assert "new: mine.py" in txt + assert "stash" in txt + + +def test_max_files_counts_the_rest(): + files = [f"f{i}.py" for i in range(1, 6)] + txt = modificationSummary({"Modifications": [mod(changed=files)]}, MaxFiles=2) + assert "changed: f2.py" in txt + assert "f3.py" not in txt + assert "... and 3 more" in txt + + +# ---- installType ------------------------------------------------------------- + + +def test_install_type_fields(): + kind, info = installType() + assert kind in {"installer", "git", "pip", "unknown"} + assert set(info) == {"SourceDir", "RepoRoot", "NdiHome", "Receipt"} + if kind == "git": + assert info["RepoRoot"] and ndi.__file__.startswith(info["RepoRoot"]) + else: + assert info["RepoRoot"] == "" + + +# ---- checkOnStartup ------------------------------------------------------------ + + +@pytest.fixture +def prefs(monkeypatch): + """An in-memory stand-in for ndi.preferences get/set.""" + store = { + "Update.CheckOnStartup": True, + "Update.CheckIntervalDays": 1.0, + "Update.Python.LastCheckTime": "", + } + monkeypatch.setattr(ndi.preferences, "get", lambda key: store[key]) + monkeypatch.setattr(ndi.preferences, "set", lambda key, value: store.__setitem__(key, value)) + monkeypatch.delenv("CI", raising=False) + return store + + +def fake_check(update=True, can_install=True): + calls = [] + + def run(**kw): + calls.append(kw) + return { + "UpdateAvailable": update, + "CanInstall": can_install, + "Message": "An NDI-python update is available: 3 commits.\nRun ndi.update.install() ...", + } + + run.calls = calls + return run + + +def test_startup_check_reports_once_per_interval(prefs, monkeypatch, capsys): + run = fake_check() + monkeypatch.setattr(check_module, "check", run) + line = checkOnStartup() + assert line.startswith("An NDI-python update is available: 3 commits.") + assert "ndi update" in line + assert prefs["Update.Python.LastCheckTime"] + assert checkOnStartup() == "" # within the interval: no second check + assert len(run.calls) == 1 + + +def test_startup_check_leaves_matlabs_time_alone(prefs, monkeypatch): + monkeypatch.setattr(check_module, "check", fake_check(update=False)) + checkOnStartup() + assert "Update.MATLAB.LastCheckTime" not in prefs + + +def test_startup_check_can_be_turned_off(prefs, monkeypatch): + run = fake_check() + monkeypatch.setattr(check_module, "check", run) + prefs["Update.CheckOnStartup"] = False + assert checkOnStartup() == "" + assert run.calls == [] + + +def test_startup_check_is_skipped_in_ci(prefs, monkeypatch): + run = fake_check() + monkeypatch.setattr(check_module, "check", run) + monkeypatch.setenv("CI", "true") + assert checkOnStartup() == "" + assert run.calls == [] + + +def test_startup_check_never_raises(prefs, monkeypatch): + def broken(**kw): + raise RuntimeError("boom") + + monkeypatch.setattr(check_module, "check", broken) + assert checkOnStartup() == "" + + +def test_update_preferences_are_registered(tmp_path): + from ndi.preferences import ndi_preferences + + p = ndi_preferences(filename=tmp_path / "NDI_Preferences.json") + assert p.get("Update.CheckOnStartup") is True + assert p.get("Update.CheckIntervalDays") == 1.0 + assert p.get("Update.Python.LastCheckTime") == "" + + +# ---- the CLI ----------------------------------------------------------------- + + +def test_cli_check_json(monkeypatch, capsys): + from ndi.__main__ import _update_main + + monkeypatch.setattr( + ndi.update, "check", lambda **kw: {"Status": "upToDate", "UpdateAvailable": False} + ) + assert _update_main(["--check", "--json"]) == 0 + assert json.loads(capsys.readouterr().out)["Status"] == "upToDate" + + +def test_cli_update_reports_refusal(monkeypatch, capsys): + from ndi.__main__ import _update_main + + def refuse(**kw): + raise ndi.update.UpdateError("ndi:update:install:notOnMain", "not on main") + + monkeypatch.setattr(ndi.update, "install", refuse) + assert _update_main(["--yes"]) == 1 + assert "not on main" in capsys.readouterr().err + + +# ---- the navigator's Update button ------------------------------------------- + + +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"] From 119df2cacf527f2fd494e04aa8d49f720bcf1284 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 21:09:32 +0000 Subject: [PATCH 2/4] Symmetry: don't expect Python's own Update.Python.* keys in MATLAB's file The preferences read-artifacts test requires every key NDI-python registers to be in the other language's NDI_Preferences.json. Update.Python.LastCheckTime is deliberately per-language (NDI-matlab keeps Update.MATLAB.LastCheckTime), so MATLAB never writes it. Keys in a "Python" subcategory are no longer expected in matlabArtifacts; the pythonArtifacts check still requires every key. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017Ni686VRCwcmMfvCVcLypp --- .../read_artifacts/util/test_preferences.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/tests/symmetry/read_artifacts/util/test_preferences.py b/tests/symmetry/read_artifacts/util/test_preferences.py index a8de64f3..919e80c2 100644 --- a/tests/symmetry/read_artifacts/util/test_preferences.py +++ b/tests/symmetry/read_artifacts/util/test_preferences.py @@ -13,7 +13,8 @@ NDI_Preferences.json with the same value; - every registered ndi.preferences default key has a corresponding entry in the JSON payload (sanity check that the make side wrote a complete - snapshot of the singleton). + snapshot of the singleton), except that Python's own per-language keys + (subcategory "Python") are not expected in MATLAB's file. """ import json @@ -80,9 +81,19 @@ def test_preferences(self, source_type): # signal. Missing-on-python keys are tolerated by recording them # via pytest's verbose output, since the make-side may have been # written by a different language with a richer preference set. + # + # One deliberate exception: a subcategory named after a language holds + # that language's own state, which the other language never registers. + # Update.Python.LastCheckTime is the case (NDI-matlab keeps + # Update.MATLAB.LastCheckTime): the two check different repositories, + # so one checking must not hold off the other. Such keys are not + # expected in the OTHER language's file -- only there; this + # language's own file must still have every one of them. items = ndi_preferences.list_items() for item in items: subcategory = item.get("subcategory", "") or "" + if subcategory == "Python" and source_type == "matlabArtifacts": + continue if subcategory: key = f"{item['category']}__{subcategory}__{item['name']}" else: From 8ab2e6b3d1ca4b109d6955add5e47f9f3becedc1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 22:16:50 +0000 Subject: [PATCH 3/4] run_git: never let git wait for input; re-sync runGit to NDI-matlab 42f532167 NDI-matlab's runGit now gives git an empty stdin and disables credential prompts, the pager and the editor. run_git does the same, and the bridge entry records the new commit. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017Ni686VRCwcmMfvCVcLypp --- src/ndi/update/_git.py | 9 ++++++++- src/ndi/update/ndi_matlab_python_bridge.yaml | 4 +++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/ndi/update/_git.py b/src/ndi/update/_git.py index 309c4e6f..29ef85f3 100644 --- a/src/ndi/update/_git.py +++ b/src/ndi/update/_git.py @@ -5,6 +5,7 @@ from __future__ import annotations +import os import subprocess from pathlib import Path @@ -12,18 +13,24 @@ def run_git(repo_dir: str | Path, *args: str, timeout: float = 120) -> tuple[int, str]: """Run ``git -C REPO_DIR ARGS...`` and return ``(status, output)``. + git never waits for input: its stdin is empty and it may not prompt for + credentials or open a pager or editor, as in MATLAB's runGit. + The output is stdout and stderr together with trailing whitespace removed. Leading whitespace is kept, because it is meaningful in ``git status --porcelain`` output. A missing git program, or a command that runs past ``timeout`` seconds, gives a non-zero status rather than an exception, as MATLAB's ``system`` does. """ + env = {**os.environ, "GIT_TERMINAL_PROMPT": "0"} try: done = subprocess.run( - ["git", "-C", str(repo_dir), *args], + ["git", "-c", "core.pager=cat", "-c", "core.editor=true", "-C", str(repo_dir), *args], capture_output=True, text=True, timeout=timeout, + stdin=subprocess.DEVNULL, + env=env, ) except (OSError, subprocess.TimeoutExpired) as exc: return 1, str(exc) diff --git a/src/ndi/update/ndi_matlab_python_bridge.yaml b/src/ndi/update/ndi_matlab_python_bridge.yaml index ba8d153e..8bfec9bb 100644 --- a/src/ndi/update/ndi_matlab_python_bridge.yaml +++ b/src/ndi/update/ndi_matlab_python_bridge.yaml @@ -209,13 +209,15 @@ functions: - name: runGit type: function matlab_path: "+ndi/+update/private/runGit.m" - matlab_last_sync_hash: "f9ced4bcd" + matlab_last_sync_hash: "42f532167" matlab_branch: "claude/amazing-clarke-a34o5c" python_path: "ndi/update/_git.py" status: ported_differently decision_log: > Private helper; here run_git in the private module _git, taking the git arguments as a list (subprocess, no shell) rather than one string. + NDI-matlab 42f532167 makes git non-interactive (empty stdin, no + credential prompt, pager or editor); run_git does the same. - name: githubApi type: function From f1e28838edf0ace45a6fede3bd518214daf6298a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 23:02:01 +0000 Subject: [PATCH 4/4] Bridge: drop the matlab_branch markers now that NDI-matlab#1038 has merged VH-Lab/NDI-matlab#1038 merged with a merge commit, so +ndi/+update is on NDI-matlab main and every recorded sync hash (f9ced4bcd, e2a3d8e57, 42f532167) is in main's history. The eleven matlab_branch markers come off, as section 8 of the bridge spec requires, and src/ndi/update joins the completeness guard's PACKAGES. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017Ni686VRCwcmMfvCVcLypp --- src/ndi/update/ndi_matlab_python_bridge.yaml | 16 +--------------- tests/test_matlab_bridge_completeness.py | 4 ++++ 2 files changed, 5 insertions(+), 15 deletions(-) diff --git a/src/ndi/update/ndi_matlab_python_bridge.yaml b/src/ndi/update/ndi_matlab_python_bridge.yaml index 8bfec9bb..c500f50b 100644 --- a/src/ndi/update/ndi_matlab_python_bridge.yaml +++ b/src/ndi/update/ndi_matlab_python_bridge.yaml @@ -1,10 +1,7 @@ # ndi_matlab_python_bridge.yaml — src/ndi/update/ # The Primary Contract for the ndi.update namespace. # -# Every MATLAB file here is on VH-Lab/NDI-matlab branch claude/amazing-clarke-a34o5c -# (VH-Lab/NDI-matlab#1038), not yet on main, so each entry carries -# matlab_branch (section 8 of docs/developer_notes/ndi_matlab_python_bridge.yaml). -# Remove the markers once that branch merges. +# The MATLAB side is +ndi/+update, added in VH-Lab/NDI-matlab#1038. # # What the two halves have in common: both check their OWN repository's main # branch (NDI-matlab: also its releases, for the toolbox), report the same @@ -23,7 +20,6 @@ functions: type: function matlab_path: "+ndi/+update/check.m" matlab_last_sync_hash: "f9ced4bcd" - matlab_branch: "claude/amazing-clarke-a34o5c" python_path: "ndi/update/check.py" input_arguments: - name: IncludeDependencies @@ -69,7 +65,6 @@ functions: type: function matlab_path: "+ndi/+update/install.m" matlab_last_sync_hash: "f9ced4bcd" - matlab_branch: "claude/amazing-clarke-a34o5c" python_path: "ndi/update/install.py" input_arguments: - name: Confirm @@ -108,7 +103,6 @@ functions: type: function matlab_path: "+ndi/+update/installType.m" matlab_last_sync_hash: "f9ced4bcd" - matlab_branch: "claude/amazing-clarke-a34o5c" python_path: "ndi/update/install_type.py" output_arguments: - name: type @@ -128,7 +122,6 @@ functions: type: function matlab_path: "+ndi/+update/modificationSummary.m" matlab_last_sync_hash: "f9ced4bcd" - matlab_branch: "claude/amazing-clarke-a34o5c" python_path: "ndi/update/modification_summary.py" input_arguments: - name: result @@ -147,7 +140,6 @@ functions: type: function matlab_path: "+ndi/+update/checkOnStartup.m" matlab_last_sync_hash: "f9ced4bcd" - matlab_branch: "claude/amazing-clarke-a34o5c" python_path: "ndi/update/check_on_startup.py" output_arguments: - name: line @@ -166,7 +158,6 @@ functions: type: function matlab_path: "+ndi/+update/checkPython.m" matlab_last_sync_hash: "e2a3d8e57" - matlab_branch: "claude/amazing-clarke-a34o5c" python_path: "ndi/update/check.py" status: ported_differently decision_log: > @@ -179,7 +170,6 @@ functions: type: function matlab_path: "+ndi/+update/compareVersions.m" matlab_last_sync_hash: "f9ced4bcd" - matlab_branch: "claude/amazing-clarke-a34o5c" status: matlab_only decision_log: > Compares release numbers for NDI-matlab's packaged toolbox. NDI-python @@ -190,7 +180,6 @@ functions: type: function matlab_path: "+ndi/+update/latestRelease.m" matlab_last_sync_hash: "f9ced4bcd" - matlab_branch: "claude/amazing-clarke-a34o5c" status: matlab_only decision_log: > Fetches NDI-matlab's latest GitHub release for its toolbox. NDI-python @@ -200,7 +189,6 @@ functions: type: function matlab_path: "+ndi/+update/toolboxIdentifier.m" matlab_last_sync_hash: "f9ced4bcd" - matlab_branch: "claude/amazing-clarke-a34o5c" status: matlab_only decision_log: > The MATLAB Add-On identifier of the NDI-matlab toolbox. A MATLAB @@ -210,7 +198,6 @@ functions: type: function matlab_path: "+ndi/+update/private/runGit.m" matlab_last_sync_hash: "42f532167" - matlab_branch: "claude/amazing-clarke-a34o5c" python_path: "ndi/update/_git.py" status: ported_differently decision_log: > @@ -223,7 +210,6 @@ functions: type: function matlab_path: "+ndi/+update/private/githubApi.m" matlab_last_sync_hash: "f9ced4bcd" - matlab_branch: "claude/amazing-clarke-a34o5c" python_path: "ndi/update/_github.py" status: ported_differently decision_log: > diff --git a/tests/test_matlab_bridge_completeness.py b/tests/test_matlab_bridge_completeness.py index ac72410b..e1d427a7 100644 --- a/tests/test_matlab_bridge_completeness.py +++ b/tests/test_matlab_bridge_completeness.py @@ -107,6 +107,9 @@ def id(self) -> str: #: gui and fun, last and largest, closed the remaining fifty-eight. See #: #154. #: +#: update joined when VH-Lab/NDI-matlab#1038 added +ndi/+update, recorded +#: in full from its first commit. +#: #: That is every MATLAB package that HAS a Python package with a bridge #: file of its own -- seventeen of MATLAB's twenty-three. The six that #: remain are not deferred work of the same kind; each is unlistable for a @@ -160,6 +163,7 @@ def id(self) -> str: BridgedPackage(python_dir="src/ndi/util", matlab_dir="+ndi/+util"), BridgedPackage(python_dir="src/ndi/gui", matlab_dir="+ndi/+gui"), BridgedPackage(python_dir="src/ndi/fun", matlab_dir="+ndi/+fun"), + BridgedPackage(python_dir="src/ndi/update", matlab_dir="+ndi/+update"), ]