From 731e08b69eec43278dd74627aa3b247bc2ee6ee9 Mon Sep 17 00:00:00 2001 From: timsit Date: Wed, 21 Oct 2026 14:11:46 +0100 Subject: [PATCH 1/7] Desktop app: a double-clickable MEA-NAP for macOS and Windows (1.13.0) A PyInstaller build with Python and every dependency inside (OASIS and the spike sorters included), for people who don't use a terminal: - packaging/: entry point, spec, icon, Inno Setup installer (per-user, no admin rights), build_app.py; built + smoke-tested by .github/workflows/desktop-app.yml on official releases, by hand, and on PRs that touch the packaging. - meanap.frozen: the few things a frozen app lacks (python -m re-entry, bundled MATLAB images, a console on Windows) as no-ops from a clone. - The app's version dialog checks GitHub's latest release over HTTPS and links to the download, instead of the git-based switcher. - meanap.app_smoke (--smoke-test): imports, numba, steps 1-4 on synthetic recordings with worker processes, and the GUI, run on the built app. Plan and status: python/DESKTOP_APP_PLAN.md. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/desktop-app.yml | 122 +++++++++++++++++ .gitignore | 4 + packaging/build_app.py | 66 +++++++++ packaging/make_icons.py | 60 +++++++++ packaging/meanap.spec | 91 +++++++++++++ packaging/meanap_app.py | 43 ++++++ packaging/windows/meanap.iss | 61 +++++++++ pyproject.toml | 6 + python/DESKTOP_APP_PLAN.md | 79 +++++++++++ python/test_desktop_app.py | 178 +++++++++++++++++++++++++ src/meanap/app_smoke.py | 173 ++++++++++++++++++++++++ src/meanap/catnap/tracking/pipeline.py | 4 +- src/meanap/frozen.py | 102 ++++++++++++++ src/meanap/gui/app.py | 4 +- src/meanap/gui/main_window.py | 17 ++- src/meanap/gui/versions_dialog.py | 98 +++++++++++++- src/meanap/pipeline/plotting_step4.py | 15 ++- src/meanap/updates.py | 83 ++++++++++++ src/meanap/version.py | 14 +- uv.lock | 96 +++++++++++++ version.txt | 2 +- 21 files changed, 1305 insertions(+), 13 deletions(-) create mode 100644 .github/workflows/desktop-app.yml create mode 100644 packaging/build_app.py create mode 100644 packaging/make_icons.py create mode 100644 packaging/meanap.spec create mode 100644 packaging/meanap_app.py create mode 100644 packaging/windows/meanap.iss create mode 100644 python/DESKTOP_APP_PLAN.md create mode 100644 python/test_desktop_app.py create mode 100644 src/meanap/app_smoke.py create mode 100644 src/meanap/frozen.py diff --git a/.github/workflows/desktop-app.yml b/.github/workflows/desktop-app.yml new file mode 100644 index 00000000..f6bd481a --- /dev/null +++ b/.github/workflows/desktop-app.yml @@ -0,0 +1,122 @@ +# The desktop app: a double-clickable MEA-NAP for macOS (Apple silicon) and +# Windows, built with PyInstaller. See python/DESKTOP_APP_PLAN.md. +# +# When it runs: +# - an official release is published (v1.13.0, …) → build both, smoke-test +# them, attach the .dmg and the Setup.exe to that release; +# - by hand (Actions → Desktop app → Run workflow) → build both, keep them as +# downloadable workflow artifacts; +# - a pull request that touches the packaging → build and smoke-test, so a +# broken build is found before the release that needs it. +# Not on every merge: development builds are hundreds of MB each, and the +# dev-build workflow tags with GITHUB_TOKEN, which starts no other workflow. + +name: Desktop app + +on: + release: + types: [published] + workflow_dispatch: + pull_request: + paths: + - "packaging/**" + - ".github/workflows/desktop-app.yml" + - "src/meanap/frozen.py" + - "src/meanap/app_smoke.py" + +permissions: + contents: write + +jobs: + build: + # Development builds are pre-releases; only official releases get apps. + if: github.event_name != 'release' || !github.event.release.prerelease + strategy: + fail-fast: false + matrix: + include: + - os: macos-14 # Apple silicon + platform: macOS-arm64 + - os: windows-latest + platform: Windows-x64 + runs-on: ${{ matrix.os }} + timeout-minutes: 60 + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # tags, for the build's version description + + - uses: astral-sh/setup-uv@v6 + + - name: Install MEA-NAP and the build tools + run: uv sync --frozen --extra oasis --extra sorting --group app + + - name: Build + run: uv run --frozen python packaging/build_app.py + + - name: Smoke test (macOS) + if: runner.os == 'macOS' + env: + QT_QPA_PLATFORM: offscreen + run: packaging/dist/MEA-NAP.app/Contents/MacOS/MEA-NAP --smoke-test + + - name: Smoke test (Windows) + if: runner.os == 'Windows' + shell: pwsh + env: + QT_QPA_PLATFORM: offscreen + # A windowed .exe has no console: wait for it, then show the log it + # wrote its output to (meanap.frozen.log_file). + run: | + $p = Start-Process -FilePath "packaging\dist\MEA-NAP\MEA-NAP.exe" ` + -ArgumentList "--smoke-test" -Wait -PassThru + Get-Content "$env:LOCALAPPDATA\MEA-NAP\logs\meanap.log" + exit $p.ExitCode + + - name: Version + id: version + run: echo "version=$(tr -d '[:space:]' < version.txt)" >> "$GITHUB_OUTPUT" + + - name: Package .dmg + if: runner.os == 'macOS' + env: + NAME: MEA-NAP-${{ steps.version.outputs.version }}-${{ matrix.platform }} + run: | + staging=packaging/build/dmg + rm -rf "$staging" && mkdir -p "$staging" + cp -R packaging/dist/MEA-NAP.app "$staging/" + ln -s /Applications "$staging/Applications" # drag-to-install + hdiutil create -volname "MEA-NAP" -srcfolder "$staging" -ov \ + -format UDZO "packaging/dist/$NAME.dmg" + echo "ASSET=packaging/dist/$NAME.dmg" >> "$GITHUB_ENV" + + - name: Package installer + if: runner.os == 'Windows' + shell: pwsh + run: | + if (-not (Get-Command iscc -ErrorAction SilentlyContinue)) { + choco install innosetup -y --no-progress + $env:Path += ";${env:ProgramFiles(x86)}\Inno Setup 6" + } + iscc /DAppVersion=${{ steps.version.outputs.version }} ` + /DSourceDir="$PWD\packaging\dist\MEA-NAP" ` + /DOutputDir="$PWD\packaging\dist" ` + packaging\windows\meanap.iss + $asset = "packaging/dist/MEA-NAP-${{ steps.version.outputs.version }}-${{ matrix.platform }}-Setup.exe" + "ASSET=$asset" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + + - uses: actions/upload-artifact@v4 + with: + name: MEA-NAP-${{ matrix.platform }} + path: ${{ env.ASSET }} + if-no-files-found: error + retention-days: 14 + + - name: Attach to the release + if: github.event_name == 'release' + env: + GH_TOKEN: ${{ github.token }} + run: gh release upload "${{ github.event.release.tag_name }}" "$ASSET" --clobber diff --git a/.gitignore b/.gitignore index d4f9f05c..f0ec1356 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,7 @@ local/ # Sphinx autosummary-generated API stub pages (regenerated on every docs build) docs/python/api/generated/ + +# Desktop app build output (packaging/build_app.py) +packaging/build/ +packaging/dist/ diff --git a/packaging/build_app.py b/packaging/build_app.py new file mode 100644 index 00000000..a3e6e3c0 --- /dev/null +++ b/packaging/build_app.py @@ -0,0 +1,66 @@ +"""Build the MEA-NAP desktop app for the platform this runs on. + + uv sync --extra oasis --extra sorting --group app + uv run python packaging/build_app.py # → packaging/dist/MEA-NAP[.app] + uv run python packaging/build_app.py --smoke # … then run the app's self-check + +PyInstaller does not cross-compile: the macOS app is built on a Mac, the +Windows one on Windows (CI does both; see .github/workflows/desktop-app.yml). +""" + +import argparse +import json +import subprocess +import sys +from pathlib import Path + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parent +BUILD = HERE / "build" +DIST = HERE / "dist" + + +def write_build_info() -> dict: + """Record the exact commit, since the app has no git to ask later.""" + def git(*args: str) -> str: + return subprocess.run(["git", "-C", str(ROOT), *args], capture_output=True, + text=True, check=True).stdout.strip() + + try: + describe = git("describe", "--tags", "--match", "v[0-9]*", "--always", "--dirty") + info = {"describe": describe, "commit": git("rev-parse", "HEAD"), + "dirty": describe.endswith("-dirty")} + except (OSError, subprocess.CalledProcessError): + info = {} + BUILD.mkdir(parents=True, exist_ok=True) + (BUILD / "_build_info.json").write_text(json.dumps(info)) + return info + + +def app_executable() -> Path: + if sys.platform == "darwin": + return DIST / "MEA-NAP.app" / "Contents" / "MacOS" / "MEA-NAP" + return DIST / "MEA-NAP" / ("MEA-NAP.exe" if sys.platform == "win32" else "MEA-NAP") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--smoke", action="store_true", + help="run the built app's --smoke-test afterwards") + args = parser.parse_args() + + subprocess.run([sys.executable, str(HERE / "make_icons.py")], check=True) + print("build info:", write_build_info()) + subprocess.run([sys.executable, "-m", "PyInstaller", str(HERE / "meanap.spec"), + "--noconfirm", "--clean", + "--distpath", str(DIST), "--workpath", str(BUILD / "pyinstaller")], + check=True) + exe = app_executable() + print(f"built {exe}") + if args.smoke: + return subprocess.run([str(exe), "--smoke-test"]).returncode + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/packaging/make_icons.py b/packaging/make_icons.py new file mode 100644 index 00000000..269beddc --- /dev/null +++ b/packaging/make_icons.py @@ -0,0 +1,60 @@ +"""Make the app icons (.ico for Windows, .icns for macOS) from the GUI logo. + +The logo is wide; an icon is square and mostly seen at 16-64 px, where the +wordmark would be unreadable. So the icon is the logo's network-drawn "N" on a +white rounded square. Run by the build (see ``build_app.py``); writes into +``packaging/build/``. +""" + +from pathlib import Path + +from PIL import Image, ImageDraw + +ROOT = Path(__file__).resolve().parents[1] +LOGO = ROOT / "src" / "meanap" / "gui" / "assets" / "mea-nap-logo.png" +OUT = Path(__file__).resolve().parent / "build" + +#: The "N" and its network, in the logo's pixel coordinates. +N_BOX = (465, 415, 705, 655) +SIZE = 1024 + + +def _glyph() -> Image.Image: + """The "N" alone: its yellow and green, without the neighbouring letters' blue + and the cartoon neurons' grey that overlap the crop.""" + import numpy as np + + px = np.asarray(Image.open(LOGO).convert("RGBA").crop(N_BOX)).copy() + r, g, b = (px[..., i].astype(int) for i in range(3)) + keep = (r - b > 60) | (g - b > 60) # yellow and green; not blue, not grey + px[~keep, 3] = 0 + return Image.fromarray(px) + + +def icon_image() -> Image.Image: + glyph = _glyph() + canvas = Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0)) + # macOS icons sit inside ~80% of their canvas with a rounded-square body. + margin = SIZE // 10 + ImageDraw.Draw(canvas).rounded_rectangle( + (margin, margin, SIZE - margin, SIZE - margin), + radius=SIZE // 5, fill=(255, 255, 255, 255), + outline=(210, 214, 220, 255), width=SIZE // 128) + inner = SIZE - 2 * margin - SIZE // 8 + glyph = glyph.resize((inner, inner), Image.LANCZOS) + offset = (SIZE - inner) // 2 + canvas.alpha_composite(glyph, (offset, offset)) + return canvas + + +def main() -> None: + OUT.mkdir(parents=True, exist_ok=True) + img = icon_image() + img.save(OUT / "icon.png") + img.save(OUT / "icon.ico", sizes=[(s, s) for s in (16, 24, 32, 48, 64, 128, 256)]) + img.save(OUT / "icon.icns") + print(f"icons → {OUT}") + + +if __name__ == "__main__": + main() diff --git a/packaging/meanap.spec b/packaging/meanap.spec new file mode 100644 index 00000000..5e7a1a04 --- /dev/null +++ b/packaging/meanap.spec @@ -0,0 +1,91 @@ +# PyInstaller spec for the MEA-NAP desktop app. Build with packaging/build_app.py, +# which makes the icons and the build-info file this reads first. +# +# One-folder build (not one-file): a one-file app unpacks ~1 GB into a temp +# folder on every launch, which is slow and trips antivirus scanners. + +import sys +from pathlib import Path + +from PyInstaller.utils.hooks import collect_data_files, collect_submodules + +ROOT = Path(SPECPATH).parent +BUILD = Path(SPECPATH) / "build" +ICON = BUILD / ("icon.icns" if sys.platform == "darwin" else "icon.ico") + +datas = [ + (str(ROOT / "src/meanap/gui/assets"), "meanap/gui/assets"), + (str(ROOT / "src/meanap/pipeline/assets"), "meanap/pipeline/assets"), + # Beside the package, where meanap.version looks when there is no repo. + (str(ROOT / "version.txt"), "meanap"), + (str(ROOT / "versions.json"), "meanap"), + (str(BUILD / "_build_info.json"), "meanap"), + # The step-4 summary figure's metric schematics (meanap.frozen.bundle_dir). + (str(ROOT / "matlab/Images"), "matlab/Images"), +] +# Pure-data packages the hooks don't cover. +for pkg in ("sciplotlib", "probeinterface"): + datas += collect_data_files(pkg) + +hiddenimports = ( + # Much of MEA-NAP is imported lazily, inside functions, and some of it by + # name (the GUI's panels, --run-module targets): take all of it. + collect_submodules("meanap") + # SpikeInterface registers sorters and extractors by import path. + + collect_submodules("spikeinterface.sorters") + + collect_submodules("spikeinterface.extractors") + + collect_submodules("spikeinterface.postprocessing") + + collect_submodules("spikeinterface.qualitymetrics") + + ["mountainsort5", "hdbscan", "oasis", "oasis.functions", "certifi"] +) + +excludes = [ + # Not used, and each would drag in hundreds of MB if something optional + # imported it. + "tkinter", "IPython", "jupyter", "notebook", "torch", "tensorflow", + "PyQt5", "PySide2", "PySide6", +] + +a = Analysis( + [str(Path(SPECPATH) / "meanap_app.py")], + pathex=[str(ROOT / "src")], + datas=datas, + hiddenimports=hiddenimports, + excludes=excludes, + noarchive=False, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + [], + exclude_binaries=True, + name="MEA-NAP", + icon=str(ICON), + # No console window behind the GUI. Output goes to the log file instead + # (meanap.frozen.prepare_runtime). --smoke-test still prints on macOS and + # Linux; on Windows CI reads the log. + console=False, + # Keep UPX off: it saves little on these libraries and is a common reason + # antivirus flags an unsigned app. + upx=False, + # macOS: arm64 only (see python/DESKTOP_APP_PLAN.md). + target_arch="arm64" if sys.platform == "darwin" else None, +) + +coll = COLLECT(exe, a.binaries, a.datas, name="MEA-NAP", upx=False) + +if sys.platform == "darwin": + app = BUNDLE( + coll, + name="MEA-NAP.app", + icon=str(ICON), + bundle_identifier="org.sandlab.meanap", + version=(ROOT / "version.txt").read_text().strip(), + info_plist={ + "CFBundleDisplayName": "MEA-NAP", + "NSHighResolutionCapable": True, + "LSMinimumSystemVersion": "12.0", + }, + ) diff --git a/packaging/meanap_app.py b/packaging/meanap_app.py new file mode 100644 index 00000000..9fbf66df --- /dev/null +++ b/packaging/meanap_app.py @@ -0,0 +1,43 @@ +"""Entry point of the packaged MEA-NAP app (PyInstaller builds this file). + +One executable does several jobs, picked by its first argument: + +* nothing (a double-click) — the GUI, exactly as ``meanap-gui``; +* ``--smoke-test`` — :mod:`meanap.app_smoke`, the build's self-check; +* ``--run-module NAME ...`` — run a module as ``python -m NAME ...`` would. The + pipeline starts some children that way (:func:`meanap.frozen.python_command`), + and a frozen app has no ``python`` to start them with; +* the worker processes ``multiprocessing`` spawns, which + ``freeze_support`` recognises and runs before anything else. +""" + +import multiprocessing +import runpy +import sys + + +def main() -> None: + from meanap import frozen + + # Before freeze_support, so spawned workers also have somewhere to print + # on a console-less Windows build. + frozen.prepare_runtime() + multiprocessing.freeze_support() + + args = sys.argv[1:] + if args[:1] == [frozen.RUN_MODULE_FLAG] and len(args) >= 2: + sys.argv = [args[1], *args[2:]] + runpy.run_module(args[1], run_name="__main__", alter_sys=True) + return + if args[:1] == ["--smoke-test"]: + from meanap.app_smoke import main as smoke + + sys.exit(smoke()) + + from meanap.gui.app import main as gui + + gui() + + +if __name__ == "__main__": + main() diff --git a/packaging/windows/meanap.iss b/packaging/windows/meanap.iss new file mode 100644 index 00000000..78363578 --- /dev/null +++ b/packaging/windows/meanap.iss @@ -0,0 +1,61 @@ +; Inno Setup script for the MEA-NAP Windows installer. +; +; iscc /DAppVersion=1.12.0 /DSourceDir=packaging\dist\MEA-NAP /DOutputDir=out packaging\windows\meanap.iss +; +; Installs for the current user only (no administrator rights, which many +; university-managed PCs don't give), into %LOCALAPPDATA%\Programs\MEA-NAP. +; Installing a newer version over an older one replaces it in place. + +#ifndef AppVersion + #error AppVersion must be defined (/DAppVersion=1.2.3) +#endif +#ifndef SourceDir + #define SourceDir "..\dist\MEA-NAP" +#endif +#ifndef OutputDir + #define OutputDir "..\dist" +#endif + +[Setup] +; Never change AppId: it is how Windows knows a new installer upgrades this app. +AppId={{35AB158D-6C41-4355-A820-B4B3B18C7EDF} +AppName=MEA-NAP +AppVersion={#AppVersion} +AppVerName=MEA-NAP {#AppVersion} +AppPublisher=SAND Lab +AppPublisherURL=https://github.com/SAND-Lab/MEA-NAP +AppSupportURL=https://github.com/SAND-Lab/MEA-NAP/issues +AppUpdatesURL=https://github.com/SAND-Lab/MEA-NAP/releases +DefaultDirName={localappdata}\Programs\MEA-NAP +DefaultGroupName=MEA-NAP +DisableProgramGroupPage=yes +PrivilegesRequired=lowest +PrivilegesRequiredOverridesAllowed=dialog +ArchitecturesAllowed=x64compatible +ArchitecturesInstallIn64BitMode=x64compatible +OutputDir={#OutputDir} +OutputBaseFilename=MEA-NAP-{#AppVersion}-Windows-x64-Setup +SetupIconFile=..\build\icon.ico +UninstallDisplayIcon={app}\MEA-NAP.exe +Compression=lzma2/max +SolidCompression=yes +WizardStyle=modern +; Close a running MEA-NAP before replacing its files. +CloseApplications=yes + +[Tasks] +Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}" + +[Files] +Source: "{#SourceDir}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs + +[InstallDelete] +; An upgrade must not leave the previous version's libraries beside the new. +Type: filesandordirs; Name: "{app}\_internal" + +[Icons] +Name: "{autoprograms}\MEA-NAP"; Filename: "{app}\MEA-NAP.exe" +Name: "{autodesktop}\MEA-NAP"; Filename: "{app}\MEA-NAP.exe"; Tasks: desktopicon + +[Run] +Filename: "{app}\MEA-NAP.exe"; Description: "{cm:LaunchProgram,MEA-NAP}"; Flags: nowait postinstall skipifsilent diff --git a/pyproject.toml b/pyproject.toml index 11cad7c2..ee05d02e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -89,6 +89,12 @@ packages = ["src/meanap"] line-length = 100 src = ["src"] +# Building the desktop app (packaging/build_app.py): `uv sync --group app`. +[dependency-groups] +app = [ + "pyinstaller>=6.22.3", +] + # The version files live at the repo root so MATLAB and Python read the same # version.txt. A wheel has no repo around it, so copy them in beside the # package or an installed MEA-NAP reports its version as "unknown". diff --git a/python/DESKTOP_APP_PLAN.md b/python/DESKTOP_APP_PLAN.md new file mode 100644 index 00000000..2a758269 --- /dev/null +++ b/python/DESKTOP_APP_PLAN.md @@ -0,0 +1,79 @@ +# Desktop app plan (Windows + macOS) + +Goal: someone who has never opened a terminal downloads one file, double-clicks +it, and gets the MEA-NAP GUI. Today's route — git clone, `uv sync`, +`uv run meanap-gui` — stays for everyone else and is unchanged by this work. + +## Decisions (2026-10-21) + +| Question | Decision | +|---|---| +| Approach | **Frozen app** (PyInstaller, one-folder build): Python and every dependency inside. No network needed to install or run. | +| Platforms | **Windows x64** and **macOS Apple silicon** (arm64). Not Intel Mac, not Linux (Linux users manage with uv). | +| Signing | **Unsigned for now.** Docs and the release page explain the Gatekeeper / SmartScreen bypass step by step. Signing can come later without changing the build. | +| Extras inside | **OASIS** (CAT-NAP denoising; without it results silently differ) and **spike sorting** (SpikeInterface + MountainSort5 + hdbscan). | +| Left out | CAT-NAP **cell tracking** (needs `roicat` → PyTorch, not a declared dependency); **Kilosort**; **launching MATLAB / old MATLAB-only releases**; switching between versions in place. | +| Distribution | `.dmg` (macOS) and a setup `.exe` (Windows, Inno Setup), built by GitHub Actions on each **official** release tag (and on demand) and attached to the GitHub release. Not on every dev build: hundreds of MB per merge. | +| Updates | The app checks GitHub's releases (public repo, no git, no login) and offers a link to download the newer one. | + +## What has to change in the code + +A frozen app has no git checkout, no `python` executable, no source tree around +the package and, on Windows, no console. Each of these breaks something today: + +1. **Entry point.** One script (`packaging/meanap_app.py`) that calls + `multiprocessing.freeze_support()` (step-1/4 workers use `spawn`) and then + starts the GUI. +2. **`sys.executable -m …` re-entry.** `updates.launch_plan` and the CAT-NAP + tracking runner start child Pythons this way; frozen, `sys.executable` is the + app itself. Add a `--run-module ` dispatch to the entry point and a + helper that builds the right argv either way. +3. **Version + build info.** `version.txt` / `versions.json` are bundled beside + the package (as the wheel already does). `build_info()` reads git; bake a + `_build_info.json` at build time instead. +4. **Repo-relative resources.** Step-4 schematics load `matlab/Images/*.png` via + `Path(__file__).parents[3]`; bundle those images and look them up in the + frozen location too. +5. **No console (Windows).** `sys.stdout`/`sys.stderr` are `None` in a windowed + build; route them to a log file in the user's data folder (also what to + attach to a bug report). +6. **numba `cache=True`.** Three JIT functions cache beside their source, which + a frozen app doesn't have (and a signed/installed bundle is read-only): + point `NUMBA_CACHE_DIR` at a user cache folder and check it actually works. +7. **Versions dialog / update check.** In the app: show the running version, + check GitHub releases over HTTPS, button to open the download page. Hide the + git-only parts (update `main`, open/set-default a release, MATLAB). + +## Phases + +1. **Freeze-safe code** — items 1–7 above, each a no-op when run from a clone, + with tests. +2. **PyInstaller spec + local Linux build.** PyInstaller isn't a cross-compiler, + but a Linux build exercises the same freezing problems (hidden imports for + numba / sklearn / statsmodels / spikeinterface, data files, spawn workers, + numba cache). Add a headless `--smoke-test` to the app: import every + pipeline, JIT the numba kernels, run the synthetic example through steps 1–4 + with 2 workers, check OASIS and the sorter import, render the GUI offscreen. +3. **CI** — `.github/workflows/desktop-app.yml`: matrix `macos-14` + + `windows-latest`; `uv sync --extra oasis --extra sorting` + PyInstaller; + run the smoke test *on the built app*; package `.dmg` / Inno Setup `.exe`; + upload as workflow artifacts, and attach to the release on official tags. +4. **Docs + release** — "Download the app" at the top of `installation.md` + with the Gatekeeper / SmartScreen steps; README link; version bump. + +## Open questions / risks + +- **Size.** Base env is ~1.1 GB unpacked; with sorting more. Expect a + 300–600 MB download. Trim later (exclude tests, unused Qt modules) if it hurts. +- **First launch is slow** (antivirus scans, numba JIT); the GUI should come + up before any JIT happens. +- **Unsigned macOS + quarantine**: on recent macOS the bypass is System + Settings → Privacy & Security → "Open Anyway". Must be tested on a real Mac. +- No macOS/Windows machine locally: real-device testing depends on the user. + +## Status + +- [ ] Phase 1 — freeze-safe code +- [ ] Phase 2 — spec + Linux build + smoke test +- [ ] Phase 3 — CI builds for macOS + Windows +- [ ] Phase 4 — docs, release diff --git a/python/test_desktop_app.py b/python/test_desktop_app.py new file mode 100644 index 00000000..49922f58 --- /dev/null +++ b/python/test_desktop_app.py @@ -0,0 +1,178 @@ +"""The desktop app's code paths, tested from source by pretending to be frozen. + + .venv/bin/python -m pytest python/test_desktop_app.py + +The real check of a build is `` --smoke-test`` (``packaging/build_app.py +--smoke``). These cover the decisions the app makes about being frozen, which a +source checkout never takes — and that each one is a no-op when not frozen. +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +from meanap import frozen, updates # noqa: E402 + +ROOT = Path(__file__).resolve().parents[1] + + +@pytest.fixture +def as_frozen(monkeypatch, tmp_path): + """Look like a PyInstaller app whose bundle is *tmp_path*.""" + monkeypatch.setattr(sys, "frozen", True, raising=False) + monkeypatch.setattr(sys, "_MEIPASS", str(tmp_path), raising=False) + return tmp_path + + +# ── python_command ─────────────────────────────────────────────────────────── + +def test_python_command_from_source_is_python_dash_m(): + assert frozen.python_command("meanap.x", "--a") == [sys.executable, "-m", "meanap.x", "--a"] + + +def test_python_command_frozen_re_enters_the_app(as_frozen): + assert frozen.python_command("meanap.x", "--a") == [ + sys.executable, frozen.RUN_MODULE_FLAG, "meanap.x", "--a"] + + +def test_entry_point_runs_a_module_like_python_dash_m(tmp_path): + """--run-module gives the module __main__ and its own argv.""" + probe = tmp_path / "probe_mod.py" + probe.write_text("import sys, json\n" + "if __name__ == '__main__':\n" + " print(json.dumps(sys.argv))\n") + env = {**os.environ, "PYTHONPATH": os.pathsep.join([str(tmp_path), str(ROOT / "src")])} + out = subprocess.run( + [sys.executable, str(ROOT / "packaging" / "meanap_app.py"), + frozen.RUN_MODULE_FLAG, "probe_mod", "--x", "1"], + capture_output=True, text=True, env=env, check=True).stdout + argv = json.loads(out) + # As with python -m: argv[0] is the module's file, then its own arguments. + assert Path(argv[0]).name == "probe_mod.py" and argv[1:] == ["--x", "1"] + + +# ── prepare_runtime ────────────────────────────────────────────────────────── + +def test_prepare_runtime_does_nothing_from_source(monkeypatch): + out, err = sys.stdout, sys.stderr + frozen.prepare_runtime() + assert (sys.stdout, sys.stderr) == (out, err) + + +def test_prepare_runtime_frozen_logs_to_file_and_children_append(tmp_path): + """No console: output goes to the log; a spawned worker adds to, not replaces, it.""" + script = ( + "import sys, os\n" + "sys.frozen = True\n" + "sys.stdout = None; sys.stderr = None\n" + "from meanap import frozen\n" + "frozen.prepare_runtime()\n" + "print('hello from', os.environ.get('ROLE'))\n" + "sys.stderr.write('stderr too\\n')\n" + ) + env = {**os.environ, "HOME": str(tmp_path), "XDG_CACHE_HOME": str(tmp_path / "cache"), + "LOCALAPPDATA": str(tmp_path / "local"), "PYTHONPATH": str(ROOT / "src")} + env.pop("MEANAP_APP_LOG_STARTED", None) + subprocess.run([sys.executable, "-c", script], env={**env, "ROLE": "parent"}, check=True) + subprocess.run([sys.executable, "-c", script], check=True, + env={**env, "ROLE": "child", "MEANAP_APP_LOG_STARTED": "1"}) + log = _log_path(tmp_path) + text = log.read_text() + assert "hello from parent" in text and "hello from child" in text + assert "stderr too" in text + # A fresh launch (no inherited marker) keeps the last run as .previous.log. + subprocess.run([sys.executable, "-c", script], env={**env, "ROLE": "again"}, check=True) + assert "hello from again" in log.read_text() + assert "hello from parent" in log.with_suffix(".previous.log").read_text() + + +def _log_path(home: Path) -> Path: + if sys.platform == "darwin": + return home / "Library" / "Logs" / "MEA-NAP" / "meanap.log" + if sys.platform == "win32": + return home / "local" / "MEA-NAP" / "logs" / "meanap.log" + return home / "cache" / "meanap" / "logs" / "meanap.log" + + +# ── bundled files ──────────────────────────────────────────────────────────── + +def test_matlab_images_found_in_repo_and_in_bundle(as_frozen, monkeypatch): + from meanap.pipeline import plotting_step4 + + assert plotting_step4._matlab_images_dir() == as_frozen / "matlab" / "Images" + monkeypatch.setattr(sys, "frozen", False) + monkeypatch.delattr(sys, "_MEIPASS") + repo = plotting_step4._matlab_images_dir() + assert repo == ROOT / "matlab" / "Images" and (repo / "ND.png").is_file() + + +def test_build_info_falls_back_to_baked_file(tmp_path, monkeypatch): + """No .git around the package: read _build_info.json beside it instead.""" + from meanap import version + + pkg = tmp_path / "a" / "b" / "meanap" + pkg.mkdir(parents=True) + (pkg / "_build_info.json").write_text(json.dumps( + {"describe": "v9.9.9-dev.1", "commit": "abc", "dirty": False})) + monkeypatch.setattr(version, "__file__", str(pkg / "version.py")) + version.build_info.cache_clear() + try: + assert version.build_info()["describe"] == "v9.9.9-dev.1" + (pkg / "_build_info.json").write_text("{}") + version.build_info.cache_clear() + assert version.build_info() == {} + finally: + version.build_info.cache_clear() + + +# ── update check ───────────────────────────────────────────────────────────── + +def _latest(tag, url="https://example/rel"): + return lambda _url: {"tag_name": tag, "html_url": url} + + +def test_check_app_newer_release(): + s = updates.check_app("v1.12.0", fetch_json=_latest("v1.13.0")) + assert s.update_available and s.newer_version == "v1.13.0" and s.url == "https://example/rel" + + +def test_check_app_same_or_older_is_current(): + assert not updates.check_app("v1.12.0", fetch_json=_latest("v1.12.0")).update_available + assert not updates.check_app("v1.12.0", fetch_json=_latest("v1.11.0")).update_available + + +def test_check_app_ignores_dev_and_junk_tags(): + for tag in ("v1.13.0-dev.4", "nightly", None): + s = updates.check_app("v1.12.0", fetch_json=_latest(tag)) + assert not s.update_available and s.error + + +def test_check_app_offline_is_an_error_not_an_exception(): + def offline(_url): + raise OSError("no network") + s = updates.check_app("v1.12.0", fetch_json=offline) + assert not s.update_available and "no network" in s.error + + +def test_app_version_dialog_offers_the_download(monkeypatch): + monkeypatch.setenv("QT_QPA_PLATFORM", "offscreen") + from PyQt6.QtWidgets import QApplication + + from meanap.gui.versions_dialog import AppVersionDialog, status_summary + + app = QApplication.instance() or QApplication([]) # noqa: F841 + dlg = AppVersionDialog() + dlg.set_status(updates.check_app("v1.12.0", fetch_json=_latest("v1.13.0"))) + assert dlg._download_button.text() == "Download v1.13.0" + assert "v1.13.0" in dlg._status_label.text() + dlg.set_status(updates.check_app("v1.12.0", fetch_json=_latest("v1.12.0"))) + assert dlg._download_button.text() == "Open download page" + assert status_summary(dlg._status) == "This is the latest official release." diff --git a/src/meanap/app_smoke.py b/src/meanap/app_smoke.py new file mode 100644 index 00000000..54178fa5 --- /dev/null +++ b/src/meanap/app_smoke.py @@ -0,0 +1,173 @@ +"""A self-check for the packaged app: `` --smoke-test``. + +A frozen build fails in ways a source checkout never shows — a module +PyInstaller didn't see imported, a data file left behind, worker processes that +re-launch the GUI instead of working. Each check here exercises one of those on +the built app, with no network and no data beyond what it makes itself: + +* every pipeline and optional extra imports (OASIS, the sorters); +* numba compiles and runs the step-4 kernel; +* two synthetic MEA recordings run through steps 1-4 with worker + *processes*, which is what proves ``spawn`` + ``freeze_support`` work; +* the GUI builds a window offscreen. + +Prints one PASS/FAIL line per check; exits non-zero if any failed. Also runs +from a clone (``python -m meanap.app_smoke``), which is how it is tested. +""" + +from __future__ import annotations + +import sys +import tempfile +import time +import traceback +from pathlib import Path +from typing import Callable + +import numpy as np + +FS = 12500.0 +SECONDS = 20.0 + + +def _write_recording(path: Path, seed: int) -> None: + """60 MCS electrodes; network bursts shared by most channels, plus noise spikes.""" + import scipy.io as sio + + from meanap.pipeline.channel_layout import get_coords_from_layout + + channels, _ = get_coords_from_layout("MCS60") + rng = np.random.default_rng(seed) + n = int(FS * SECONDS) + dat = rng.normal(0, 1.0, size=(n, len(channels))).astype(np.float32) + spike = (-10.0 * np.exp(-((np.arange(-15, 16)) ** 2) / 12.0)).astype(np.float32) + bursts = np.sort(rng.uniform(0.5, SECONDS - 1.0, size=int(SECONDS / 2))) + for ch in range(len(channels)): + times = list(rng.uniform(0.1, SECONDS - 0.1, size=int(SECONDS * 2))) + for b in bursts: + if rng.random() < 0.8: + times += list(b + np.cumsum(rng.exponential(0.008, size=5))) + for t in times: + at = int(t * FS) + if 15 <= at < n - 16: + dat[at - 15:at + 16, ch] += spike + sio.savemat(path, {"dat": dat, "channels": np.asarray(channels, float), "fs": FS}) + + +def check_imports() -> str: + import importlib + + for name in ("meanap.pipeline.runner", "meanap.catnap.pipeline", "meanap.stim.activity", + "meanap.stats.dataset", "meanap.viewer.server", "meanap.cli", + "statsmodels.formula.api", "sklearn.decomposition", "openpyxl", "h5py"): + importlib.import_module(name) + from meanap.catnap.denoising import oasis_available + if not oasis_available(): + raise RuntimeError("OASIS is not available") + from meanap.pipeline.spike_sorting import installed_sorters + sorters = installed_sorters() + if "mountainsort5" not in sorters: + raise RuntimeError(f"MountainSort5 missing; sorters: {sorters}") + return f"sorters: {', '.join(sorters)}" + + +def check_numba() -> str: + import numba + + from meanap.pipeline import null_models + + if not getattr(null_models, "_HAVE_NUMBA", True): + raise RuntimeError("null_models fell back to pure Python") + adj = np.random.default_rng(0).random((20, 20)) + adj = np.triu(adj, 1) + np.triu(adj, 1).T + null_models.randmio_und_signed(adj, 2, rng=np.random.default_rng(1)) + return f"numba {numba.__version__}" + + +def check_pipeline() -> str: + import pandas as pd + + from meanap.params import Params + from meanap.pipeline.runner import run_pipeline + + with tempfile.TemporaryDirectory() as tmp: + tmp = Path(tmp) + raw = tmp / "raw" + raw.mkdir() + names = ["smokeA_DIV14", "smokeB_DIV14"] + for i, name in enumerate(names): + _write_recording(raw / f"{name}.mat", seed=i) + pd.DataFrame([{"Recording filename": n, "DIV": 14, "Group": g} + for n, g in zip(names, ["WT", "KO"])]).to_csv(tmp / "recs.csv", index=False) + params = Params( + raw_data=str(raw), output_data_folder=str(tmp), output_data_folder_name="Smoke", + spreadsheet_file_name=str(tmp / "recs.csv"), spreadsheet_range="2:100", + start_analysis_step=1, stop_analysis_step=4, channel_layout="MCS60", + func_con_lag_val=[10], prob_thresh_rep_num=20, random_seed=3, + min_number_of_nodes_to_cal_net_met=2, + # Worker *processes*, not threads: the frozen spawn path is the point. + spike_detection_channel_workers=2, recording_workers=2, + ) + lines: list[str] = [] + root = run_pipeline(params, log=lines.append) + degraded = [ln for ln in lines if "could not start worker processes" in ln] + if degraded: + raise RuntimeError(f"worker processes did not start: {degraded[0]}") + need = [root / "2_NeuronalActivity" / "NeuronalActivity_RecordingLevel.csv", + root / "4_NetworkActivity" / "NetworkActivity_RecordingLevel.csv"] + missing = [str(p.relative_to(root)) for p in need if not p.exists()] + if missing: + raise RuntimeError(f"missing outputs: {missing}") + pngs = len(list(root.rglob("*.png"))) + if not pngs: + raise RuntimeError("no figures were drawn") + return f"{pngs} figures" + + +def check_gui() -> str: + import os + + os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + from PyQt6.QtWidgets import QApplication + + from meanap.gui import theme + from meanap.gui.main_window import MainWindow + + theme.enable_hi_dpi() + app = QApplication.instance() or QApplication(sys.argv[:1]) + theme.apply(app) + window = MainWindow() + window.show() + app.processEvents() + size = window.grab().size() + window.close() + return f"window {size.width()}x{size.height()}" + + +CHECKS: list[tuple[str, Callable[[], str]]] = [ + ("imports and optional extras", check_imports), + ("numba JIT", check_numba), + ("steps 1-4 on synthetic recordings, with worker processes", check_pipeline), + ("GUI window (offscreen)", check_gui), +] + + +def main() -> int: + from meanap.version import all_versions, build_label + + print(f"MEA-NAP smoke test — {all_versions()} {build_label() or ''}".rstrip(), flush=True) + failed = 0 + for name, fn in CHECKS: + start = time.monotonic() + try: + detail = fn() + print(f" PASS {name} ({detail}; {time.monotonic() - start:.0f}s)", flush=True) + except Exception: # noqa: BLE001 - report every failure, then carry on + failed += 1 + print(f" FAIL {name}\n{traceback.format_exc()}", flush=True) + print("smoke test " + ("passed" if not failed else f"FAILED ({failed})"), flush=True) + return 1 if failed else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/meanap/catnap/tracking/pipeline.py b/src/meanap/catnap/tracking/pipeline.py index 80a6b908..46292c97 100644 --- a/src/meanap/catnap/tracking/pipeline.py +++ b/src/meanap/catnap/tracking/pipeline.py @@ -47,6 +47,7 @@ verify_offsets, ) from meanap.catnap.tracking.roicat import match_rates, run_chain, stage_session +from meanap.frozen import python_command from meanap.catnap.tracking.validate import ( NEUCOEFF, auc_vs_null, @@ -697,7 +698,6 @@ def _track_parallel(ordered, recordings, source, out_dir, *, min_shift_px, import os import shutil import subprocess - import sys import tempfile from concurrent.futures import ThreadPoolExecutor, as_completed @@ -778,7 +778,7 @@ def launch(chain): env["TMPDIR"] = str(tmp) try: proc = subprocess.run( - [sys.executable, "-m", "meanap.catnap.tracking", "--spec", str(spec)], + python_command("meanap.catnap.tracking", "--spec", str(spec)), capture_output=True, text=True, timeout=7200, env=env) finally: shutil.rmtree(tmp, ignore_errors=True) diff --git a/src/meanap/frozen.py b/src/meanap/frozen.py new file mode 100644 index 00000000..ee4545af --- /dev/null +++ b/src/meanap/frozen.py @@ -0,0 +1,102 @@ +"""What changes when MEA-NAP runs as the packaged desktop app. + +The app (see ``packaging/``) is a PyInstaller build: Python and every +dependency inside one folder, started by double-clicking. Three things that a +source checkout takes for granted are missing there, and this module is where +the rest of the code asks about them, so that each answer is a no-op from a +clone: + +* **No ``python``.** ``sys.executable`` is the app itself, so ``python -m X`` + becomes `` --run-module X`` — :func:`python_command`. +* **No source tree.** Files that a clone finds relative to the repo (the + MATLAB folder's schematics) are bundled and found under + :func:`bundle_dir` instead. +* **No console** on Windows: ``sys.stdout`` and ``sys.stderr`` are ``None``, + and the first ``print`` to stderr would raise. :func:`prepare_runtime` sends + both to a log file, which is also the file to attach to a bug report. + +numba's ``cache=True`` needs nothing here: with no source file to cache beside, +numba itself caches in the user's cache folder (``~/Library/Caches/numba``, +``%LOCALAPPDATA%\\numba``), keyed to the app's executable so a new version +never loads an old build's machine code. +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +APP_NAME = "MEA-NAP" + +#: The flag the app's entry point reads to run a module the way ``python -m`` +#: would. Kept here, beside :func:`python_command`, so the two cannot drift. +RUN_MODULE_FLAG = "--run-module" + + +def is_frozen() -> bool: + """True inside the packaged app, False when run from Python.""" + return bool(getattr(sys, "frozen", False)) + + +def bundle_dir() -> Path | None: + """The folder the app's bundled files were unpacked into, or ``None``.""" + base = getattr(sys, "_MEIPASS", None) + return Path(base) if base else None + + +def python_command(module: str, *args: str) -> list[str]: + """``argv`` that runs *module* as ``__main__`` in a fresh process. + + ``[python, "-m", module, ...]`` from a clone; the app re-entering itself + with :data:`RUN_MODULE_FLAG` when frozen. + """ + if is_frozen(): + return [sys.executable, RUN_MODULE_FLAG, module, *args] + return [sys.executable, "-m", module, *args] + + +def log_file() -> Path: + """Where the app's output goes, following each OS's habit for logs.""" + home = Path.home() + if sys.platform == "darwin": + folder = home / "Library" / "Logs" / APP_NAME + elif sys.platform == "win32": + base = os.environ.get("LOCALAPPDATA") or str(home / "AppData" / "Local") + folder = Path(base) / APP_NAME / "logs" + else: + base = os.environ.get("XDG_CACHE_HOME") or str(home / ".cache") + folder = Path(base) / "meanap" / "logs" + return folder / "meanap.log" + + +def prepare_runtime() -> None: + """Set up the frozen app's process. Does nothing outside the app.""" + if not is_frozen(): + return + if sys.stdout is None or sys.stderr is None: + _log_to_file() + + +#: Set once the app has started its log, so the worker processes it spawns +#: (which inherit the environment) append to that log instead of rotating it. +_LOG_STARTED_ENV = "MEANAP_APP_LOG_STARTED" + + +def _log_to_file() -> None: + """Send stdout/stderr to the log file, keeping the previous run's copy.""" + path = log_file() + child = os.environ.get(_LOG_STARTED_ENV) == "1" + try: + path.parent.mkdir(parents=True, exist_ok=True) + if path.exists() and not child: + path.replace(path.with_suffix(".previous.log")) + stream = open(path, "a" if child else "w", buffering=1, # noqa: SIM115 + encoding="utf-8", errors="replace") + os.environ[_LOG_STARTED_ENV] = "1" + except OSError: + stream = open(os.devnull, "w") # noqa: SIM115 - nowhere to write: drop it + if sys.stdout is None: + sys.stdout = stream + if sys.stderr is None: + sys.stderr = stream diff --git a/src/meanap/gui/app.py b/src/meanap/gui/app.py index b2800fb4..a1ec4ea4 100644 --- a/src/meanap/gui/app.py +++ b/src/meanap/gui/app.py @@ -5,6 +5,7 @@ from PyQt6.QtWidgets import QApplication +from meanap.frozen import is_frozen from meanap.gui import theme from meanap.gui.branding import logo_icon from meanap.gui.main_window import MainWindow @@ -63,7 +64,8 @@ def _open_default_version(argv: list[str]) -> None: def main() -> None: args = _parse_args(sys.argv[1:]) - if not args.here: + # The desktop app is one fixed version, with no clone to open another from. + if not args.here and not is_frozen(): _open_default_version(sys.argv[1:]) theme.enable_hi_dpi() diff --git a/src/meanap/gui/main_window.py b/src/meanap/gui/main_window.py index b22011a3..0ebf2d6c 100644 --- a/src/meanap/gui/main_window.py +++ b/src/meanap/gui/main_window.py @@ -46,7 +46,10 @@ from meanap.gui.panels.stats import StatsPanel from meanap.gui.tooltip import install_tooltip_style, wrap_tooltips from meanap.gui.tutorial import TutorialOverlay, TutorialStep, tabbar_target -from meanap.gui.versions_dialog import Background, VersionsDialog, status_summary +from meanap.frozen import is_frozen +from meanap.gui.versions_dialog import ( + AppVersionDialog, Background, VersionsDialog, status_summary, +) from meanap.gui.combo import install_combo_popup_fit from meanap.gui.wheel import install_wheel_guard @@ -527,7 +530,8 @@ def _refresh_version_label(self) -> None: "params.json and bundle manifest.") if status is not None: tip += "\n\n" + status_summary(status) - label.setToolTip(tip + "\n\nClick to update MEA-NAP or choose another version.") + label.setToolTip(tip + "\n\nClick to update MEA-NAP" + + ("." if is_frozen() else " or choose another version.")) # ── Versions and updates ────────────────────────────────────────────────── @@ -541,7 +545,7 @@ def start_update_check(self) -> None: """ from meanap import updates - job = Background(updates.check, self) + job = Background(updates.check_app if is_frozen() else updates.check, self) job.done.connect(self._on_update_status) job.start() self._update_job = job @@ -553,6 +557,13 @@ def _on_update_status(self, status) -> None: self._versions_dialog.set_status(status) def _on_show_versions(self) -> None: + if self._versions_dialog is None and is_frozen(): + self._versions_dialog = AppVersionDialog(self) + self._versions_dialog.status_changed.connect(self._on_update_status) + if self._update_status is not None: + self._versions_dialog.set_status(self._update_status) + else: + self._versions_dialog.refresh() if self._versions_dialog is None: self._versions_dialog = VersionsDialog( self, diff --git a/src/meanap/gui/versions_dialog.py b/src/meanap/gui/versions_dialog.py index 03cfcb4b..d9be3ec0 100644 --- a/src/meanap/gui/versions_dialog.py +++ b/src/meanap/gui/versions_dialog.py @@ -111,10 +111,17 @@ def _run(self) -> None: pass -def status_summary(status: updates.Status | None) -> str: +def status_summary(status: updates.Status | updates.AppStatus | None) -> str: """One line about the running copy's freshness, for the dialog and tooltip.""" if status is None: return "Checking GitHub for updates…" + if isinstance(status, updates.AppStatus): + if status.newer_version: + return (f"A newer release, {status.newer_version}, is available " + "to download.") + if status.latest_release: + return "This is the latest official release." + return status.error or "" co = status.checkout if co is None: return status.error or "Not a git clone." @@ -558,3 +565,92 @@ def _on_show_folder(self) -> None: f"folder will be created at {folder}.") return QDesktopServices.openUrl(QUrl.fromLocalFile(str(folder))) + + +class AppVersionDialog(QDialog): + """The versions dialog of the packaged desktop app. + + The app is not a clone, so the git half of :class:`VersionsDialog` — + updating ``main``, opening another release beside this one — has nothing + to act on. What is left: what is running, whether GitHub has a newer + official release, and the page to download it from. + """ + + status_changed = pyqtSignal(object) + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + from meanap.version import build_label + + self.setWindowTitle("MEA-NAP version") + self.setMinimumWidth(460) + self._status: updates.AppStatus | None = None + self._jobs: list[Background] = [] + + layout = QVBoxLayout(self) + build = build_label() + running = QLabel(f"MEA-NAP {meanap_version()} — desktop app" + + (f"
build {build}" + if build else "")) + running.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) + layout.addWidget(running) + self._status_label = QLabel() + self._status_label.setWordWrap(True) + layout.addWidget(self._status_label) + + row = QHBoxLayout() + self._check_button = QPushButton("Check again") + self._check_button.setAutoDefault(False) + self._check_button.clicked.connect(self.refresh) + self._download_button = QPushButton("Open download page") + self._download_button.setDefault(True) + self._download_button.setToolTip( + "Opens the release on GitHub in your browser. Download the file for " + "your computer and install it over this one; your settings are kept.") + self._download_button.clicked.connect(self._on_download) + row.addWidget(self._check_button) + row.addWidget(self._download_button) + row.addStretch(1) + layout.addLayout(row) + + note = QLabel( + "Development builds and older releases run from a git clone of " + "MEA-NAP rather than the app — see the installation guide.") + note.setWordWrap(True) + note.setStyleSheet("color: palette(mid);") + layout.addWidget(note) + + self._auto_check = QCheckBox("Check for updates when MEA-NAP starts") + self._auto_check.setChecked(check_on_start()) + self._auto_check.toggled.connect(set_check_on_start) + layout.addWidget(self._auto_check) + self._render() + + def set_status(self, status: updates.AppStatus | None) -> None: + self._status = status + self._render() + + def _render(self) -> None: + self._status_label.setText(status_summary(self._status)) + self._download_button.setText( + f"Download {self._status.newer_version}" + if self._status is not None and self._status.newer_version + else "Open download page") + + def refresh(self) -> None: + self._check_button.setEnabled(False) + self._status_label.setText("Checking GitHub for updates…") + job = Background(updates.check_app, self) + job.done.connect(self._on_checked) + job.failed.connect(lambda _msg: self._check_button.setEnabled(True)) + self._jobs.append(job) + job.start() + + def _on_checked(self, status: updates.AppStatus) -> None: + self._check_button.setEnabled(True) + self.set_status(status) + self.status_changed.emit(status) + + def _on_download(self) -> None: + url = self._status.url if self._status is not None else updates.RELEASES_PAGE + QDesktopServices.openUrl(QUrl(url)) diff --git a/src/meanap/pipeline/plotting_step4.py b/src/meanap/pipeline/plotting_step4.py index ec5abc34..cc2fa596 100644 --- a/src/meanap/pipeline/plotting_step4.py +++ b/src/meanap/pipeline/plotting_step4.py @@ -397,6 +397,16 @@ def plot_spatial_network_combined( } +def _matlab_images_dir() -> Path: + """MATLAB's ``Images`` folder: in the repo, or bundled inside the desktop app.""" + from meanap.frozen import bundle_dir + + bundled = bundle_dir() + if bundled is not None: + return bundled / "matlab" / "Images" + return Path(__file__).resolve().parents[3] / "matlab" / "Images" + + _CARTOGRAPHY_DIAGRAM_CACHE: "np.ndarray | None | bool" = False # False = not yet loaded @@ -412,7 +422,7 @@ def _load_cartography_diagram() -> "np.ndarray | None": return _CARTOGRAPHY_DIAGRAM_CACHE candidates = [ Path(__file__).resolve().parent / "assets" / "NodeCartographyDiagram.jpg", - Path(__file__).resolve().parents[3] / "matlab" / "Images" / "NodeCartographyDiagram.jpg", + _matlab_images_dir() / "NodeCartographyDiagram.jpg", ] for p in candidates: if p.exists(): @@ -1787,8 +1797,7 @@ def plot_graph_metrics_by_node( # ── Resolve schematic images directory ─────────────────────────────────── if images_dir is None: - # Repo root = three levels above src/meanap/pipeline/ - images_dir = Path(__file__).parent.parent.parent.parent / "matlab" / "Images" + images_dir = _matlab_images_dir() SCHEMATICS = [ ("ND.png", "node degree"), diff --git a/src/meanap/updates.py b/src/meanap/updates.py index 04d08bdd..8a8c137b 100644 --- a/src/meanap/updates.py +++ b/src/meanap/updates.py @@ -447,6 +447,89 @@ def _explain_merge_failure(err: str, repo: Path) -> str: return f"Not updated: {err}" +# ── The desktop app ────────────────────────────────────────────────────────── +# +# The packaged app (see packaging/) is not a clone: no git, no history, and its +# folder may be read-only. So there is nothing to fast-forward and no release +# to check out beside it. What it can still do is ask GitHub whether a newer +# official release exists and send the user to download it — over HTTPS from +# the public API, needing no git and no login. + +RELEASES_PAGE = f"https://github.com/{UPSTREAM}/releases" +_LATEST_RELEASE_API = f"https://api.github.com/repos/{UPSTREAM}/releases/latest" + + +@dataclass +class AppStatus: + """The outcome of one :func:`check_app`.""" + + running: str # "v1.12.0" + latest_release: str | None = None + url: str = RELEASES_PAGE # where to download it + error: str | None = None + + @property + def newer_version(self) -> str | None: + latest = self.latest_release + if latest and _tag_key(latest) > _tag_key(self.running): + return latest + return None + + @property + def update_available(self) -> bool: + return self.newer_version is not None + + +def check_app(running: str | None = None, *, fetch_json=None) -> AppStatus: + """Ask GitHub for the newest official release (never a development build). + + *fetch_json* is for tests: a callable from URL to the decoded JSON. + """ + if running is None: + from meanap.version import meanap_version + + running = f"v{meanap_version()}" + status = AppStatus(running=running) + try: + data = (fetch_json or _fetch_json)(_LATEST_RELEASE_API) + except Exception as exc: # noqa: BLE001 - offline is normal, never fatal + status.error = f"Could not reach GitHub to check for updates ({exc})." + return status + tag = data.get("tag_name") if isinstance(data, dict) else None + if not is_version_tag(tag) or is_dev_tag(tag): + status.error = "GitHub did not say which release is the latest." + return status + status.latest_release = tag + status.url = data.get("html_url") or RELEASES_PAGE + return status + + +def _fetch_json(url: str): + import json + import urllib.request + + request = urllib.request.Request(url, headers={ + "Accept": "application/vnd.github+json", "User-Agent": "MEA-NAP"}) + with urllib.request.urlopen(request, timeout=NETWORK_TIMEOUT_S, + context=_ssl_context()) as response: + return json.load(response) + + +def _ssl_context(): + """The system's certificates, or certifi's where Python can't find them. + + A frozen app on macOS carries its own OpenSSL, which does not know where + the system keeps its certificates, so every HTTPS request would fail. + """ + import ssl + + try: + import certifi + except ImportError: + return ssl.create_default_context() + return ssl.create_default_context(cafile=certifi.where()) + + # ── Running a release ──────────────────────────────────────────────────────── def versions_dir() -> Path: diff --git a/src/meanap/version.py b/src/meanap/version.py index e3e1b1f1..cc3a6d7b 100644 --- a/src/meanap/version.py +++ b/src/meanap/version.py @@ -60,6 +60,7 @@ _VERSION_TXT = "version.txt" _VERSIONS_JSON = "versions.json" +_BUILD_INFO_JSON = "_build_info.json" def _version_file(name: str) -> Path | None: @@ -137,11 +138,20 @@ def build_info() -> dict[str, object]: ``v1.11.0-dev.23`` on a tagged build or ``v1.11.0-dev.23-2-g1a2b3c4`` two commits past it, with ``-dirty`` when files were edited. Empty when there is no git checkout (an installed wheel) or no git — the version number is - then all there is to say, and saying nothing is better than guessing. + then all there is to say, and saying nothing is better than guessing. The + desktop app has no git either, so its build writes the same three fields + into ``_build_info.json`` beside this module (``packaging/build_app.py``). """ root = Path(__file__).resolve().parents[2] if not (root / ".git").exists(): - return {} + baked = _version_file(_BUILD_INFO_JSON) + if baked is None: + return {} + try: + info = json.loads(baked.read_text()) + except (OSError, json.JSONDecodeError): + return {} + return info if isinstance(info, dict) and info.get("describe") else {} env = {**os.environ, "GIT_TERMINAL_PROMPT": "0", "LC_ALL": "C"} def git(*args: str) -> str: diff --git a/uv.lock b/uv.lock index 2b38d20c..83d398f6 100644 --- a/uv.lock +++ b/uv.lock @@ -16,6 +16,15 @@ resolution-markers = [ "python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] +[[package]] +name = "altgraph" +version = "0.17.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/f8/97fdf103f38fed6792a1601dbc16cc8aac56e7459a9fff08c812d8ae177a/altgraph-0.17.5.tar.gz", hash = "sha256:c87b395dd12fabde9c99573a9749d67da8d29ef9de0125c7f536699b4a9bc9e7", size = 48428, upload-time = "2025-11-21T20:35:50.583Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/ba/000a1996d4308bc65120167c21241a3b205464a2e0b58deda26ae8ac21d1/altgraph-0.17.5-py2.py3-none-any.whl", hash = "sha256:f3a22400bce1b0c701683820ac4f3b159cd301acab067c51c653e06961600597", size = 21228, upload-time = "2025-11-21T20:35:49.444Z" }, +] + [[package]] name = "annotated-types" version = "0.8.0" @@ -747,6 +756,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/db/41/b96f440c7df5ebba07872cad4e30fbc3560387755b1ea0b629adb76d5ca8/llvmlite-0.48.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d0b3c61aac83b42fb48cc96bffbf57c81b82b2aa92276b7ed6420c814629a99a", size = 42986383, upload-time = "2026-07-01T18:42:37.544Z" }, ] +[[package]] +name = "macholib" +version = "1.16.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "altgraph", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/10/2f/97589876ea967487978071c9042518d28b958d87b17dceb7cdc1d881f963/macholib-1.16.4.tar.gz", hash = "sha256:f408c93ab2e995cd2c46e34fe328b130404be143469e41bc366c807448979362", size = 59427, upload-time = "2025-11-22T08:28:38.373Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/d1/a9f36f8ecdf0fb7c9b1e78c8d7af12b8c8754e74851ac7b94a8305540fc7/macholib-1.16.4-py2.py3-none-any.whl", hash = "sha256:da1a3fa8266e30f0ce7e97c6a54eefaae8edd1e5f86f3eb8b95457cae90265ea", size = 38117, upload-time = "2025-11-22T08:28:36.939Z" }, +] + [[package]] name = "matplotlib" version = "3.11.0" @@ -856,6 +877,11 @@ sorting = [ { name = "spikeinterface" }, ] +[package.dev-dependencies] +app = [ + { name = "pyinstaller" }, +] + [package.metadata] requires-dist = [ { name = "h5py", specifier = ">=3.11" }, @@ -890,6 +916,9 @@ requires-dist = [ ] provides-extras = ["dev", "oasis", "sorting"] +[package.metadata.requires-dev] +app = [{ name = "pyinstaller", specifier = ">=6.22.3" }] + [[package]] name = "mountainsort5" version = "0.5.9" @@ -1195,6 +1224,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/70/cd3cf5cff538323076a879edef02cce6f13f7d75e03d12f211b0a0dbd178/patsy-1.0.3-py2.py3-none-any.whl", hash = "sha256:d3dbebe8fd5f46e29912d030b63c6268647b59bf788a99e2af28a30234cf357c", size = 233318, upload-time = "2026-08-29T21:35:30.913Z" }, ] +[[package]] +name = "pefile" +version = "2024.8.26" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/03/4f/2750f7f6f025a1507cd3b7218691671eecfd0bbebebe8b39aa0fe1d360b8/pefile-2024.8.26.tar.gz", hash = "sha256:3ff6c5d8b43e8c37bb6e6dd5085658d658a7a0bdcd20b6a07b1fcfc1c4e9d632", size = 76008, upload-time = "2024-08-26T20:58:38.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/16/12b82f791c7f50ddec566873d5bdd245baa1491bac11d15ffb98aecc8f8b/pefile-2024.8.26-py3-none-any.whl", hash = "sha256:76f8b485dcd3b1bb8166f1128d395fa3d87af26360c2358fb75b80019b957c6f", size = 74766, upload-time = "2024-08-26T21:01:02.632Z" }, +] + [[package]] name = "pillow" version = "12.2.0" @@ -1473,6 +1511,46 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] +[[package]] +name = "pyinstaller" +version = "6.22.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "altgraph" }, + { name = "macholib", marker = "sys_platform == 'darwin'" }, + { name = "packaging" }, + { name = "pefile", marker = "sys_platform == 'win32'" }, + { name = "pyinstaller-hooks-contrib" }, + { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/41/f90302845945abd4ed647933ff5ee7c6ac93983187be67f897b6cb613331/pyinstaller-6.22.3.tar.gz", hash = "sha256:05eb2f5615503e72939a7224d68b4aff572c6b0438ee4a17d0a4b481f399362d", size = 4437180, upload-time = "2026-09-12T21:54:09.669Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/af/898f70947e27f8e65d55183c780e77112e023c0b4a6fba6255d4d563a27d/pyinstaller-6.22.3-py3-none-macosx_10_13_universal2.whl", hash = "sha256:052f4a1cd4f81092ccb7a18fe8ebc9ecf832a5917317e39fb3775af4e959b253", size = 1071847, upload-time = "2026-09-12T21:53:01.711Z" }, + { url = "https://files.pythonhosted.org/packages/c4/96/99f5184985141c2b024bfa61335e9142ae444555224f303186e59b7bb438/pyinstaller-6.22.3-py3-none-manylinux2014_aarch64.whl", hash = "sha256:312da84b4b31ab9750066a823734c11b7c68757fdd67c9f038bf19c330a954e4", size = 758172, upload-time = "2026-09-12T21:53:06.171Z" }, + { url = "https://files.pythonhosted.org/packages/69/9f/9ccb12cc1e6617042cb028fe79f0a756f5f03305bd14b584a4e043fe3844/pyinstaller-6.22.3-py3-none-manylinux2014_i686.whl", hash = "sha256:020d4fe0627f5c73dfbcfad1a6578ef354d5fe4bdccd9d8e5a615528f5a45d2b", size = 772135, upload-time = "2026-09-12T21:53:10.291Z" }, + { url = "https://files.pythonhosted.org/packages/c1/43/4b1d5193a88628c052db89f064433681076959a9f41d0d9f660001b49079/pyinstaller-6.22.3-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:9e4f7bd64b2103f14b4a6240fb9155e9fe7bc61391b4043bbddb7d3ebc8ab0fa", size = 770461, upload-time = "2026-09-12T21:53:14.592Z" }, + { url = "https://files.pythonhosted.org/packages/cb/83/f173f8757c618d83e116fa2b8e57c5cd796e5ad09792e664eabd6ac5edae/pyinstaller-6.22.3-py3-none-manylinux2014_s390x.whl", hash = "sha256:f82d0fb89f21c3ed6f482ee16f341bc59e8a31dbabb9bae9ef04e53ade4dc0d9", size = 764997, upload-time = "2026-09-12T21:53:19.079Z" }, + { url = "https://files.pythonhosted.org/packages/7b/b6/dd21d61fc5e25377be24eabf13e99e4e46548a5abe2bf7b15d8b3d9cc74a/pyinstaller-6.22.3-py3-none-manylinux2014_x86_64.whl", hash = "sha256:451a4ae14b719365bf1a2f0a99dae7b3463060061c3a394c70d5264cfb439528", size = 764974, upload-time = "2026-09-12T21:53:23.176Z" }, + { url = "https://files.pythonhosted.org/packages/6c/fd/d9590942972b1fc205519f7ccffb0d5cd8eb1d6fa61edad319c71d25baed/pyinstaller-6.22.3-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:ea240b5dfa831ff673c97505eee0ec24670bccf71396c83109440348944c83d0", size = 764450, upload-time = "2026-09-12T21:53:27.419Z" }, + { url = "https://files.pythonhosted.org/packages/72/d6/f22c55d1768433b65c460b19a3136dd9769b6c74b786149b829344654c7d/pyinstaller-6.22.3-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:a2b6fc26601e8c2596c3d54981566934a3382e85d05d0fe91b55b6a8dcc6b282", size = 763863, upload-time = "2026-09-12T21:53:31.609Z" }, + { url = "https://files.pythonhosted.org/packages/c5/87/cf28cfd42ceca889171e6dd5e7f1553d4372db83d047049ffb7cf95fcbd4/pyinstaller-6.22.3-py3-none-win32.whl", hash = "sha256:46a3e30a118341a1ec20667a5a95aa7e0aa8c4fa129893c0151b3eb3f801a722", size = 1390905, upload-time = "2026-09-12T21:53:38.133Z" }, + { url = "https://files.pythonhosted.org/packages/24/83/c5422204772a27aef643280fdc887b7ef55679989096b21dceb1b9625fc4/pyinstaller-6.22.3-py3-none-win_amd64.whl", hash = "sha256:500bd58c7bf7e584a8435adccbd763a0b918d5c12b08d74ff50fd79b2915458b", size = 1454907, upload-time = "2026-09-12T21:53:44.812Z" }, + { url = "https://files.pythonhosted.org/packages/67/2e/ebc93968bf63f81b122e205da116b16036b653b7ce2cc8ad86e3ddd19586/pyinstaller-6.22.3-py3-none-win_arm64.whl", hash = "sha256:31df3cc4261e804a53d358b7897c293ec8b88b167a7cca06c78018085c855f97", size = 1402956, upload-time = "2026-09-12T21:53:51.369Z" }, +] + +[[package]] +name = "pyinstaller-hooks-contrib" +version = "2026.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/25/e47c6188228281372be7e9911a93931e01ec8798c02c93abd680b9b9521f/pyinstaller_hooks_contrib-2026.9.tar.gz", hash = "sha256:2748767901acb5cecd1304652b6c7add5bffcff1d71d01207fe24a5d42942a56", size = 180783, upload-time = "2026-10-20T15:43:54Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/fe/ded0e20de0580257d97dbabb4db7ab4d96ad8ade7d41149e66308057cf52/pyinstaller_hooks_contrib-2026.9-py3-none-any.whl", hash = "sha256:fb3e67dd64122561724c48ad0366ac9e8c16e638b63bf7d85de03bf3fdbf06c3", size = 462931, upload-time = "2026-10-20T15:43:52.632Z" }, +] + [[package]] name = "pyparsing" version = "3.3.2" @@ -1665,6 +1743,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/68/d2/a8065103f5e2e613b916489e6c85af6402a1ec64f346d1429e2d32cb8d03/pywavelets-1.9.0-cp314-cp314t-win_amd64.whl", hash = "sha256:3b6ff6ba4f625d8c955f68c2c39b0a913776d406ab31ee4057f34ad4019fb33b", size = 4306793, upload-time = "2025-08-04T16:20:02.934Z" }, ] +[[package]] +name = "pywin32-ctypes" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" }, +] + [[package]] name = "quantities" version = "0.16.4" @@ -1928,6 +2015,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/83/11/00d3c3dfc25ad54e731d91449895a79e4bf2384dc3ac01809010ba88f6d5/seaborn-0.13.2-py3-none-any.whl", hash = "sha256:636f8336facf092165e27924f223d3c62ca560b1f2bb5dff7ab7fad265361987", size = 294914, upload-time = "2024-01-25T13:21:49.598Z" }, ] +[[package]] +name = "setuptools" +version = "84.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/70/b3/c19426f783614d625699a4b247faf628ea9ab1db228674491767b417f9e1/setuptools-84.2.1.tar.gz", hash = "sha256:3ff8b3b5492a0aa66960f4419365a86b84e013070d4313e4d4a6894f5d64d6bc", size = 1230289, upload-time = "2026-10-20T19:01:37.742Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/55/6dc6d6bb953bdfc52519a0059592c52b6e3b8ad51cf8256dffceb55b0610/setuptools-84.2.1-py3-none-any.whl", hash = "sha256:ca140b45d5325a9478f880e351484f62fef15f336462e449ca0c03c873d589c2", size = 876263, upload-time = "2026-10-20T19:01:35.678Z" }, +] + [[package]] name = "six" version = "1.17.0" diff --git a/version.txt b/version.txt index 0eed1a29..feaae22b 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.12.0 +1.13.0 From 11c4d6aafc58a8ac0fd9fecf322551fff4831cc4 Mon Sep 17 00:00:00 2001 From: timsit Date: Wed, 21 Oct 2026 14:13:36 +0100 Subject: [PATCH 2/7] Desktop app CI: C++17 for isosplit6 on macOS; UTF-8 on Windows Co-Authored-By: Claude Opus 5.5 --- .github/workflows/desktop-app.yml | 7 ++ README.md | 2 +- docs/python/installation.md | 115 ++++++++++++++++++++++++++++-- packaging/make_icons.py | 2 +- 4 files changed, 120 insertions(+), 6 deletions(-) diff --git a/.github/workflows/desktop-app.yml b/.github/workflows/desktop-app.yml index f6bd481a..07f89b2b 100644 --- a/.github/workflows/desktop-app.yml +++ b/.github/workflows/desktop-app.yml @@ -44,6 +44,9 @@ jobs: defaults: run: shell: bash + env: + # Windows consoles default to cp1252; MEA-NAP prints the odd arrow. + PYTHONUTF8: "1" steps: - uses: actions/checkout@v4 with: @@ -52,6 +55,10 @@ jobs: - uses: astral-sh/setup-uv@v6 - name: Install MEA-NAP and the build tools + # isosplit6 (MountainSort5) has no macOS wheel and is C++11 that + # doesn't say so; Apple's clang defaults to an older standard. + env: + CXXFLAGS: ${{ runner.os == 'macOS' && '-std=c++17' || '' }} run: uv sync --frozen --extra oasis --extra sorting --group app - name: Build diff --git a/README.md b/README.md index 0e335441..add3fde4 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ > [!IMPORTANT] > **MEA-NAP is now available in Python (beta).** It is much faster and introduces a lot of new features. The MATLAB version is still supported. > -> [**Read the release note**](release_notes/python-beta.md) · [**Install**](docs/python/installation.md) · [**Quickstart**](docs/python/quickstart.md) · [**MATLAB vs Python**](docs/python/matlab-vs-python.md) · [**Send us feedback**](https://github.com/SAND-Lab/MEA-NAP/discussions) +> [**Read the release note**](release_notes/python-beta.md) · [**Download the app**](docs/python/installation.md#the-desktop-app-easiest) (Windows, Mac) · [**Install from source**](docs/python/installation.md#from-the-source-code) · [**Quickstart**](docs/python/quickstart.md) · [**MATLAB vs Python**](docs/python/matlab-vs-python.md) · [**Send us feedback**](https://github.com/SAND-Lab/MEA-NAP/discussions) ![Visitors](https://api.visitorbadge.io/api/visitors?path=https%3A%2F%2Fgithub.com%2FSAND-Lab%2FAnalysisPipeline&label=Visitors&countColor=%23263759) [![Documentation Status](https://readthedocs.org/projects/analysis-pipeline/badge/?version=latest)](https://analysis-pipeline.readthedocs.io/en/latest/?badge=latest) diff --git a/docs/python/installation.md b/docs/python/installation.md index 28e418d0..748e82da 100644 --- a/docs/python/installation.md +++ b/docs/python/installation.md @@ -1,5 +1,112 @@ # Installation +There are two ways to get the Python version of MEA-NAP: + +- **The desktop app** — download one file, double-click, done. No Python, no + terminal. For Windows and Apple-silicon Macs (M1 or later). +- **From the source code** — a clone of the repository and a few commands in a + terminal. For Linux and Intel Macs, for the newest code between releases, + and for anyone who wants to change MEA-NAP itself. + +Both run the same pipeline with the same pinned libraries, so they give the +same results. + +## The desktop app (easiest) + +Download the file for your computer from the +[**latest release**](https://github.com/SAND-Lab/MEA-NAP/releases/latest) +on GitHub, under **Assets**: + +| Computer | File | +|---|---| +| Windows 10 or 11 (64-bit) | `MEA-NAP--Windows-x64-Setup.exe` | +| Mac with Apple silicon (M1, M2, M3, M4 …) | `MEA-NAP--macOS-arm64.dmg` | + +Not sure which Mac you have? Apple menu → **About This Mac**: "Chip: Apple M…" +means Apple silicon; "Processor: Intel" means an Intel Mac, which the app +doesn't support — install from source instead. + +The app is a large download (several hundred MB): it carries its own Python +and every library MEA-NAP uses, including OASIS for CAT-NAP and the spike +sorters. + +:::::{tab-set} + +::::{tab-item} Windows + +1. Double-click `MEA-NAP-…-Setup.exe`. +2. Windows may show **"Windows protected your PC"**. This appears for any + app that is not yet signed with a paid certificate. Click + **More info**, then **Run anyway**. +3. Click through the installer. It installs for your user only and doesn't + need administrator rights. Tick **Create a desktop shortcut** if you want one. +4. Start MEA-NAP from the Start menu (or the desktop shortcut). + +To update, download the newer installer and run it: it replaces the old +version and keeps your settings. To uninstall: **Settings → Apps → Installed +apps → MEA-NAP → Uninstall**. + +:::: + +::::{tab-item} macOS + +1. Double-click `MEA-NAP-…-macOS-arm64.dmg`, then drag **MEA-NAP** onto + the **Applications** folder in the window that opens. +2. Open MEA-NAP from Applications (or Launchpad). The first time, macOS says + it **"could not verify MEA-NAP is free of malware"** (older macOS: "cannot + be opened because the developer cannot be verified"). This appears for any + app that is not yet notarised by Apple. Click **Done**. +3. Open **System Settings → Privacy & Security**, scroll down to the line + about MEA-NAP, and click **Open Anyway**. Enter your password if asked, + then **Open**. + + On macOS 14 (Sonoma) or earlier you can instead right-click (or + Control-click) MEA-NAP in Applications and choose **Open**, then **Open** + again. +4. From then on MEA-NAP opens normally. + +To update, download the newer `.dmg` and drag MEA-NAP into Applications +again, replacing the old one. To uninstall, drag MEA-NAP from Applications to +the Bin. + +If macOS instead says MEA-NAP **"is damaged and can't be opened"**, the +download was quarantined in a way step 3 doesn't clear. Open **Terminal** +(Applications → Utilities), paste this line, press Return, and open MEA-NAP +again: + +```bash +xattr -dr com.apple.quarantine /Applications/MEA-NAP.app +``` + +:::: + +::::: + +**The first analysis is slower than later ones.** Parts of step 4 are compiled +for your processor the first time they run, and the result is kept for next +time. On Windows, antivirus software may also scan the app the first time it +starts, so the window can take a minute to appear. + +**Updates.** The version number in the toolbar, beside the **Mode** +selector, shows **⬆ update** when a newer release is out. Click it, then +**Download**. + +**What the app doesn't include.** Running MATLAB or switching between +MEA-NAP versions from inside the GUI, CAT-NAP's cell tracking across days +(which needs PyTorch), and Kilosort. Use a source install for those. + +**Something went wrong?** The app writes everything it would have printed to +a log file. Please attach it when you +[report a problem](https://github.com/SAND-Lab/MEA-NAP/issues): + +- Windows: `%LOCALAPPDATA%\MEA-NAP\logs\meanap.log` (paste that into the + File Explorer address bar) +- macOS: `~/Library/Logs/MEA-NAP/meanap.log` (Finder → Go → Go to Folder…) + +The log of the run before is kept beside it as `meanap.previous.log`. + +## From the source code + The Python port requires **Python 3.11 or later** (the repository itself is pinned to 3.13 via `.python-version`) and is installed straight from a clone of the MEA-NAP repository — it isn't on PyPI yet. @@ -14,7 +121,7 @@ That `cd` matters: every install command on this page is run from inside the its subfolders. If you downloaded a ZIP from GitHub instead of cloning, the folder is called `MEA-NAP-main` — use `cd MEA-NAP-main`. -## macOS prerequisites +### macOS prerequisites On macOS some dependencies have no pre-built wheel for your Python version or chip and are compiled from source during the install, which needs a C/C++ @@ -126,7 +233,7 @@ Both errors mean the same thing: the command ran in the wrong directory. Run inside `python/`, run `cd ..` and try again. ::: -## Optional: OASIS deconvolution (CAT-NAP) +### Optional: OASIS deconvolution (CAT-NAP) The calcium-imaging denoising pipeline ([CAT-NAP](catnap.md)) deconvolves each trace with [OASIS](https://github.com/j-friedrich/OASIS). It is optional, and @@ -156,7 +263,7 @@ Note that denoising output is cached as `Fdenoised.npy` and carries no record of which method produced it, so installing OASIS after a run will not recompute anything — tick **Redo denoising** to force it. -## Optional: spike sorting +### Optional: spike sorting Step 1 can [sort spikes into units](spike-sorting.md) instead of detecting them per electrode, so that each neuron — not each electrode — is a node of @@ -180,7 +287,7 @@ uv run python -c "from meanap.pipeline.spike_sorting import installed_sorters; p should list `tridesclous2` and `mountainsort5`. Kilosort4 users can add `kilosort` (and a CUDA PyTorch) themselves; it then appears in the same list. -## Verifying your install +### Verifying your install The fastest sanity check is the GUI's own **🧪 Test pipeline** button — it downloads a small bundled example dataset and runs the full 4-step pipeline diff --git a/packaging/make_icons.py b/packaging/make_icons.py index 269beddc..65fac785 100644 --- a/packaging/make_icons.py +++ b/packaging/make_icons.py @@ -53,7 +53,7 @@ def main() -> None: img.save(OUT / "icon.png") img.save(OUT / "icon.ico", sizes=[(s, s) for s in (16, 24, 32, 48, 64, 128, 256)]) img.save(OUT / "icon.icns") - print(f"icons → {OUT}") + print(f"icons -> {OUT}") if __name__ == "__main__": From 628fc4a18b52db7c227b1cee6e89c21d81cfdb83 Mon Sep 17 00:00:00 2001 From: timsit Date: Wed, 21 Oct 2026 14:20:50 +0100 Subject: [PATCH 3/7] Desktop app: real download sizes in the docs; plan status Co-Authored-By: Claude Opus 5.5 --- docs/python/installation.md | 2 +- python/DESKTOP_APP_PLAN.md | 28 +++++++++++++++++++--------- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/docs/python/installation.md b/docs/python/installation.md index 748e82da..678538d7 100644 --- a/docs/python/installation.md +++ b/docs/python/installation.md @@ -26,7 +26,7 @@ Not sure which Mac you have? Apple menu → **About This Mac**: "Chip: Apple M means Apple silicon; "Processor: Intel" means an Intel Mac, which the app doesn't support — install from source instead. -The app is a large download (several hundred MB): it carries its own Python +The download is about 150 MB (about 700 MB once installed): it carries its own Python and every library MEA-NAP uses, including OASIS for CAT-NAP and the spike sorters. diff --git a/python/DESKTOP_APP_PLAN.md b/python/DESKTOP_APP_PLAN.md index 2a758269..5e74841a 100644 --- a/python/DESKTOP_APP_PLAN.md +++ b/python/DESKTOP_APP_PLAN.md @@ -37,9 +37,9 @@ the package and, on Windows, no console. Each of these breaks something today: 5. **No console (Windows).** `sys.stdout`/`sys.stderr` are `None` in a windowed build; route them to a log file in the user's data folder (also what to attach to a bug report). -6. **numba `cache=True`.** Three JIT functions cache beside their source, which - a frozen app doesn't have (and a signed/installed bundle is read-only): - point `NUMBA_CACHE_DIR` at a user cache folder and check it actually works. +6. **numba `cache=True`.** Turned out to need nothing: with no source file, + numba caches in its own user-wide folder, keyed to the executable's stat + (verified on the Linux build). `NUMBA_CACHE_DIR` would be *ignored* there. 7. **Versions dialog / update check.** In the app: show the running version, check GitHub releases over HTTPS, button to open the download page. Hide the git-only parts (update `main`, open/set-default a release, MATLAB). @@ -63,8 +63,7 @@ the package and, on Windows, no console. Each of these breaks something today: ## Open questions / risks -- **Size.** Base env is ~1.1 GB unpacked; with sorting more. Expect a - 300–600 MB download. Trim later (exclude tests, unused Qt modules) if it hurts. +- **Size.** ~700 MB installed; the compressed downloads are 126-159 MB. Trim later (exclude tests, unused Qt modules) if it hurts. - **First launch is slow** (antivirus scans, numba JIT); the GUI should come up before any JIT happens. - **Unsigned macOS + quarantine**: on recent macOS the bypass is System @@ -73,7 +72,18 @@ the package and, on Windows, no console. Each of these breaks something today: ## Status -- [ ] Phase 1 — freeze-safe code -- [ ] Phase 2 — spec + Linux build + smoke test -- [ ] Phase 3 — CI builds for macOS + Windows -- [ ] Phase 4 — docs, release +- [x] Phase 1 — freeze-safe code (`meanap.frozen`, `updates.check_app`, + `AppVersionDialog`, `_build_info.json`, bundled `matlab/Images`); + `python/test_desktop_app.py` +- [x] Phase 2 — `packaging/` spec + `build_app.py`; Linux build (668 MB + unpacked) passes `--smoke-test`: workers really spawn (7 app processes + seen), numba caches, `--run-module` works +- [x] Phase 3 — CI (`.github/workflows/desktop-app.yml`, PR #165) builds both + and their `--smoke-test` passes on macos-14 and windows-latest (4/4 + checks each); `.dmg` 159 MB, `Setup.exe` 126 MB; ~5-7 min per job. + Fixes needed: `CXXFLAGS=-std=c++17` for isosplit6 on macOS, `PYTHONUTF8` +- [ ] Phase 4 — docs written (`installation.md` "The desktop app", README + link); **merge only together with cutting the v1.13.0 release**, since + they send people to the latest release's assets +- [ ] Try the CI-built apps on a real Mac and a real Windows PC (Gatekeeper / + SmartScreen steps, first-launch time, a real dataset) From 63f04ba6378d3ae4bd43d1462a7376a2a29f59fe Mon Sep 17 00:00:00 2001 From: timsit Date: Wed, 21 Oct 2026 18:54:03 +0100 Subject: [PATCH 4/7] =?UTF-8?q?Desktop=20app:=20faster=20start=20=E2=80=94?= =?UTF-8?q?=20keep=20matplotlib's=20font=20cache,=20splash=20screen,=20sta?= =?UTF-8?q?rtup=20timing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PyInstaller's matplotlib hook gives every launch a fresh temporary MPLCONFIGDIR, so the app (and each worker process) re-scanned every system font on each start. Keep a lasting cache folder instead. Show a splash before the main window's pipeline imports, log how long the window took, and add --time-startup (run cold + warm in CI). Co-Authored-By: Claude Opus 5.5 --- .github/workflows/desktop-app.yml | 26 ++++++++++++++++ packaging/meanap_app.py | 5 ++++ python/DESKTOP_APP_PLAN.md | 13 ++++++-- python/test_desktop_app.py | 46 +++++++++++++++++++++++++++++ src/meanap/app_smoke.py | 49 +++++++++++++++++++++++++++++++ src/meanap/frozen.py | 38 +++++++++++++++++++++++- src/meanap/gui/app.py | 49 +++++++++++++++++++++++++++++-- 7 files changed, 220 insertions(+), 6 deletions(-) diff --git a/.github/workflows/desktop-app.yml b/.github/workflows/desktop-app.yml index 07f89b2b..7a647899 100644 --- a/.github/workflows/desktop-app.yml +++ b/.github/workflows/desktop-app.yml @@ -64,6 +64,32 @@ jobs: - name: Build run: uv run --frozen python packaging/build_app.py + # Before the smoke test, so the first run is a cold start: nothing read + # from disk or scanned by antivirus yet. The second shows a warm start. + - name: Startup time (macOS) + if: runner.os == 'macOS' + env: + QT_QPA_PLATFORM: offscreen + run: | + for run in cold warm; do + echo "== $run start" + packaging/dist/MEA-NAP.app/Contents/MacOS/MEA-NAP --time-startup + done + + - name: Startup time (Windows) + if: runner.os == 'Windows' + shell: pwsh + env: + QT_QPA_PLATFORM: offscreen + run: | + foreach ($run in "cold", "warm") { + "== $run start" + $p = Start-Process -FilePath "packaging\dist\MEA-NAP\MEA-NAP.exe" ` + -ArgumentList "--time-startup" -Wait -PassThru + Get-Content "$env:LOCALAPPDATA\MEA-NAP\logs\meanap.log" + if ($p.ExitCode -ne 0) { exit $p.ExitCode } + } + - name: Smoke test (macOS) if: runner.os == 'macOS' env: diff --git a/packaging/meanap_app.py b/packaging/meanap_app.py index 9fbf66df..b215dde3 100644 --- a/packaging/meanap_app.py +++ b/packaging/meanap_app.py @@ -4,6 +4,7 @@ * nothing (a double-click) — the GUI, exactly as ``meanap-gui``; * ``--smoke-test`` — :mod:`meanap.app_smoke`, the build's self-check; +* ``--time-startup`` — how long the GUI takes to come up, library by library; * ``--run-module NAME ...`` — run a module as ``python -m NAME ...`` would. The pipeline starts some children that way (:func:`meanap.frozen.python_command`), and a frozen app has no ``python`` to start them with; @@ -33,6 +34,10 @@ def main() -> None: from meanap.app_smoke import main as smoke sys.exit(smoke()) + if args[:1] == ["--time-startup"]: + from meanap.app_smoke import time_startup + + sys.exit(time_startup()) from meanap.gui.app import main as gui diff --git a/python/DESKTOP_APP_PLAN.md b/python/DESKTOP_APP_PLAN.md index 5e74841a..950cd2f8 100644 --- a/python/DESKTOP_APP_PLAN.md +++ b/python/DESKTOP_APP_PLAN.md @@ -64,8 +64,17 @@ the package and, on Windows, no console. Each of these breaks something today: ## Open questions / risks - **Size.** ~700 MB installed; the compressed downloads are 126-159 MB. Trim later (exclude tests, unused Qt modules) if it hurts. -- **First launch is slow** (antivirus scans, numba JIT); the GUI should come - up before any JIT happens. +- **Slow start (reported on Windows, 2026-10-21).** Causes found: (a) + PyInstaller's matplotlib runtime hook sets a *new temporary* `MPLCONFIGDIR` + on every launch, so matplotlib re-scanned every system font each time the + app, and every worker process, started; `frozen.prepare_runtime` now keeps a + lasting one (Linux build: matplotlib import 1.4 s → 0.2 s). (b) The main + window imports the whole pipeline (~2,750 modules, 255 native libraries, incl. + numba's ~100 MB LLVM DLL), which Windows antivirus scans on first load; a + splash screen now shows before those imports. ` --time-startup` prints a + per-library breakdown, and CI runs it cold + warm on both OSes. If Windows is + still slow, next candidates: defer numba (`null_models`, `sttc`) and + spikeinterface until a run needs them. - **Unsigned macOS + quarantine**: on recent macOS the bypass is System Settings → Privacy & Security → "Open Anyway". Must be tested on a real Mac. - No macOS/Windows machine locally: real-device testing depends on the user. diff --git a/python/test_desktop_app.py b/python/test_desktop_app.py index 49922f58..4e9c07e8 100644 --- a/python/test_desktop_app.py +++ b/python/test_desktop_app.py @@ -63,8 +63,10 @@ def test_entry_point_runs_a_module_like_python_dash_m(tmp_path): def test_prepare_runtime_does_nothing_from_source(monkeypatch): out, err = sys.stdout, sys.stderr + mpl = os.environ.get("MPLCONFIGDIR") frozen.prepare_runtime() assert (sys.stdout, sys.stderr) == (out, err) + assert os.environ.get("MPLCONFIGDIR") == mpl def test_prepare_runtime_frozen_logs_to_file_and_children_append(tmp_path): @@ -94,6 +96,24 @@ def test_prepare_runtime_frozen_logs_to_file_and_children_append(tmp_path): assert "hello from parent" in log.with_suffix(".previous.log").read_text() +def test_prepare_runtime_frozen_keeps_the_matplotlib_cache(tmp_path): + """PyInstaller's hook gives each launch a new MPLCONFIGDIR; the app overrides it.""" + script = ( + "import sys, os\n" + "sys.frozen = True\n" + "os.environ['MPLCONFIGDIR'] = 'per-launch-temp'\n" + "from meanap import frozen\n" + "frozen.prepare_runtime()\n" + "print(os.environ['MPLCONFIGDIR'])\n" + ) + env = {**os.environ, "HOME": str(tmp_path), "XDG_CACHE_HOME": str(tmp_path / "cache"), + "LOCALAPPDATA": str(tmp_path / "local"), "PYTHONPATH": str(ROOT / "src")} + runs = [subprocess.run([sys.executable, "-c", script], env=env, check=True, + capture_output=True, text=True).stdout.strip() for _ in range(2)] + assert runs[0] == runs[1] != 'per-launch-temp' + assert Path(runs[0]).is_dir() and Path(runs[0]).is_relative_to(tmp_path) + + def _log_path(home: Path) -> Path: if sys.platform == "darwin": return home / "Library" / "Logs" / "MEA-NAP" / "meanap.log" @@ -176,3 +196,29 @@ def test_app_version_dialog_offers_the_download(monkeypatch): dlg.set_status(updates.check_app("v1.12.0", fetch_json=_latest("v1.12.0"))) assert dlg._download_button.text() == "Open download page" assert status_summary(dlg._status) == "This is the latest official release." + + +def test_gui_entry_point_leaves_the_pipeline_for_after_the_splash(): + # Everything gui.app imports loads before the splash screen can show, so a + # pipeline import creeping in there means seconds of blank screen again. + code = ("import sys, meanap.gui.app; " + "heavy = [m for m in ('meanap.pipeline.runner', 'meanap.gui.main_window', " + "'scipy.signal', 'numba', 'pandas') if m in sys.modules]; " + "print(heavy); sys.exit(1 if heavy else 0)") + result = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True) + assert result.returncode == 0, result.stdout + result.stderr + + +def test_time_startup_reports_every_stage(): + # In its own process, as the app runs it: a QApplication made here would be + # shared with (and alter) every later GUI test in the session. + from meanap.app_smoke import STARTUP_IMPORTS + + result = subprocess.run( + [sys.executable, "-c", "import sys; from meanap.app_smoke import time_startup; " + "sys.exit(time_startup())"], + capture_output=True, text=True, env={**os.environ, "QT_QPA_PLATFORM": "offscreen"}) + assert result.returncode == 0, result.stdout + result.stderr + for name in STARTUP_IMPORTS: + assert f"import {name}" in result.stdout + assert "build and show the main window" in result.stdout and "total" in result.stdout diff --git a/src/meanap/app_smoke.py b/src/meanap/app_smoke.py index 54178fa5..658816d1 100644 --- a/src/meanap/app_smoke.py +++ b/src/meanap/app_smoke.py @@ -152,6 +152,55 @@ def check_gui() -> str: ] +#: What the main window pulls in, heaviest first — timed one by one by +#: :func:`time_startup`, so a slow start on someone's PC can be pinned on a +#: library rather than guessed at. +STARTUP_IMPORTS = ("numpy", "scipy.signal", "scipy.stats", "matplotlib.pyplot", + "pandas", "numba", "sklearn.decomposition", "h5py", + "seaborn", "spikeinterface.sorters", "meanap.pipeline.runner", + "meanap.gui.main_window") + + +def time_startup() -> int: + """`` --time-startup``: how long the GUI takes to come up, and why. + + Run it twice: the first run after an install (or a reboot) is the one that + pays for disk reads and the antivirus scan of every library, the second + shows what is left once those are cached. + """ + import importlib + import os + + os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + started = time.perf_counter() + lap = started + + def report(what: str) -> None: + nonlocal lap + now = time.perf_counter() + print(f" {now - lap:6.2f} s {what}", flush=True) + lap = now + + print("startup timing:", flush=True) + from PyQt6.QtWidgets import QApplication + + from meanap.gui import theme + app = QApplication.instance() or QApplication(sys.argv[:1]) + theme.apply(app) + report("Qt (the splash screen appears after this)") + for name in STARTUP_IMPORTS: + importlib.import_module(name) + report(f"import {name}") + from meanap.gui.main_window import MainWindow + window = MainWindow() + window.show() + app.processEvents() + report("build and show the main window") + print(f" {time.perf_counter() - started:6.2f} s total", flush=True) + window.close() + return 0 + + def main() -> int: from meanap.version import all_versions, build_label diff --git a/src/meanap/frozen.py b/src/meanap/frozen.py index ee4545af..c6ef957e 100644 --- a/src/meanap/frozen.py +++ b/src/meanap/frozen.py @@ -14,6 +14,12 @@ * **No console** on Windows: ``sys.stdout`` and ``sys.stderr`` are ``None``, and the first ``print`` to stderr would raise. :func:`prepare_runtime` sends both to a log file, which is also the file to attach to a bug report. +* **A throwaway matplotlib folder.** PyInstaller's runtime hook points + ``MPLCONFIGDIR`` at a new temporary folder on every launch (it is written for + one-file builds, whose fonts move each run), so matplotlib re-scans every + font on the system each time the app — or any of its worker processes — + starts: seconds on a PC with many fonts. The app is a one-folder build whose + files stay put, so :func:`prepare_runtime` gives matplotlib a lasting folder. numba's ``cache=True`` needs nothing here: with no source file to cache beside, numba itself caches in the user's cache folder (``~/Library/Caches/numba``, @@ -70,12 +76,42 @@ def log_file() -> Path: return folder / "meanap.log" +def cache_dir() -> Path: + """The app's own cache folder, following each OS's habit for caches.""" + home = Path.home() + if sys.platform == "darwin": + return home / "Library" / "Caches" / APP_NAME + if sys.platform == "win32": + base = os.environ.get("LOCALAPPDATA") or str(home / "AppData" / "Local") + return Path(base) / APP_NAME / "cache" + base = os.environ.get("XDG_CACHE_HOME") or str(home / ".cache") + return Path(base) / "meanap" / "app-cache" + + def prepare_runtime() -> None: - """Set up the frozen app's process. Does nothing outside the app.""" + """Set up the frozen app's process. Does nothing outside the app. + + Must run before anything imports matplotlib. + """ if not is_frozen(): return if sys.stdout is None or sys.stderr is None: _log_to_file() + _keep_matplotlib_cache() + + +def _keep_matplotlib_cache() -> None: + """Replace the hook's per-launch MPLCONFIGDIR with one that lasts. + + If the app is moved or upgraded, matplotlib notices the fonts it cached are + gone and rebuilds the cache once by itself. + """ + folder = cache_dir() / "matplotlib" + try: + folder.mkdir(parents=True, exist_ok=True) + except OSError: + return # keep the hook's temporary folder: slow, but works + os.environ["MPLCONFIGDIR"] = str(folder) #: Set once the app has started its log, so the worker processes it spawns diff --git a/src/meanap/gui/app.py b/src/meanap/gui/app.py index a1ec4ea4..cf5e0ae2 100644 --- a/src/meanap/gui/app.py +++ b/src/meanap/gui/app.py @@ -2,16 +2,23 @@ import argparse import sys +import time -from PyQt6.QtWidgets import QApplication +from PyQt6.QtCore import Qt +from PyQt6.QtGui import QColor, QPainter, QPixmap +from PyQt6.QtWidgets import QApplication, QSplashScreen from meanap.frozen import is_frozen from meanap.gui import theme -from meanap.gui.branding import logo_icon -from meanap.gui.main_window import MainWindow +from meanap.gui.branding import logo_icon, logo_pixmap from meanap.gui.modes import DEFAULT_MODE, MODES from meanap.gui.versions_dialog import check_on_start, default_version +# Kept light on purpose: the main window imports the whole pipeline (scipy, +# numba, pandas, spikeinterface …), which takes seconds — on Windows, with +# antivirus scanning every library the first time, much longer. Everything +# imported up here comes before the splash screen can appear. + def _parse_args(argv: list[str]) -> argparse.Namespace: parser = argparse.ArgumentParser( @@ -62,7 +69,36 @@ def _open_default_version(argv: list[str]) -> None: updates.exec_into(updates.launch_plan(folder), tuple(argv)) +def _show_splash(app: QApplication, mode: str) -> QSplashScreen | None: + """The logo, on screen while the main window's imports load.""" + ratio = app.devicePixelRatio() + logo = logo_pixmap(200, ratio, mode=mode) + if logo is None: + return None + # On a white card, with room for the message below: the logo itself is + # transparent, which a frameless window may show as black. + pad, below = 24, 36 + width = round(logo.width() / ratio) + 2 * pad + height = round(logo.height() / ratio) + pad + below + card = QPixmap(round(width * ratio), round(height * ratio)) + card.setDevicePixelRatio(ratio) + card.fill(QColor("white")) + painter = QPainter(card) + painter.drawPixmap(pad, pad, logo) + painter.setPen(QColor("#c8c8c8")) + painter.drawRect(0, 0, width - 1, height - 1) + painter.end() + splash = QSplashScreen(card) + splash.showMessage("Starting MEA-NAP …", + Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignHCenter, + QColor("#555555")) + splash.show() + app.processEvents() + return splash + + def main() -> None: + started = time.perf_counter() args = _parse_args(sys.argv[1:]) # The desktop app is one fixed version, with no clone to open another from. if not args.here and not is_frozen(): @@ -77,9 +113,16 @@ def main() -> None: app.setWindowIcon(logo_icon()) theme.apply(app) + splash = _show_splash(app, args.mode) + + from meanap.gui.main_window import MainWindow window = MainWindow(mode=args.mode) window.show() + if splash is not None: + splash.finish(window) + print(f"MEA-NAP: window ready {time.perf_counter() - started:.1f} s after start", + file=sys.stderr) if check_on_start(): window.start_update_check() sys.exit(app.exec()) From c170e520520907416c82df962ec4d9b499e1066a Mon Sep 17 00:00:00 2001 From: timsit Date: Wed, 21 Oct 2026 19:26:31 +0100 Subject: [PATCH 5/7] Desktop app: Intel Mac build numba/llvmlite stopped shipping Intel-Mac wheels after 0.62/0.45, so Intel Macs resolve to those (and numpy 2.3) via a platform marker; every other platform's lock is unchanged. Add a macos-15-intel job, build for the native architecture, and compile source-only extensions for macOS 12. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/desktop-app.yml | 11 +- packaging/meanap.spec | 6 +- pyproject.toml | 6 +- uv.lock | 214 +++++++++++++++++++++++++----- 4 files changed, 195 insertions(+), 42 deletions(-) diff --git a/.github/workflows/desktop-app.yml b/.github/workflows/desktop-app.yml index 7a647899..6d8a7cf8 100644 --- a/.github/workflows/desktop-app.yml +++ b/.github/workflows/desktop-app.yml @@ -1,5 +1,5 @@ -# The desktop app: a double-clickable MEA-NAP for macOS (Apple silicon) and -# Windows, built with PyInstaller. See python/DESKTOP_APP_PLAN.md. +# The desktop app: a double-clickable MEA-NAP for macOS (Apple silicon and +# Intel) and Windows, built with PyInstaller. See python/DESKTOP_APP_PLAN.md. # # When it runs: # - an official release is published (v1.13.0, …) → build both, smoke-test @@ -37,6 +37,8 @@ jobs: include: - os: macos-14 # Apple silicon platform: macOS-arm64 + - os: macos-15-intel # Intel + platform: macOS-Intel - os: windows-latest platform: Windows-x64 runs-on: ${{ matrix.os }} @@ -56,9 +58,12 @@ jobs: - name: Install MEA-NAP and the build tools # isosplit6 (MountainSort5) has no macOS wheel and is C++11 that - # doesn't say so; Apple's clang defaults to an older standard. + # doesn't say so; Apple's clang defaults to an older standard. It (and + # OASIS on Intel) is compiled here, by default for the runner's own + # macOS only: target the app's minimum (LSMinimumSystemVersion). env: CXXFLAGS: ${{ runner.os == 'macOS' && '-std=c++17' || '' }} + MACOSX_DEPLOYMENT_TARGET: ${{ runner.os == 'macOS' && '12.0' || '' }} run: uv sync --frozen --extra oasis --extra sorting --group app - name: Build diff --git a/packaging/meanap.spec b/packaging/meanap.spec index 5e7a1a04..6c049352 100644 --- a/packaging/meanap.spec +++ b/packaging/meanap.spec @@ -70,8 +70,10 @@ exe = EXE( # Keep UPX off: it saves little on these libraries and is a common reason # antivirus flags an unsigned app. upx=False, - # macOS: arm64 only (see python/DESKTOP_APP_PLAN.md). - target_arch="arm64" if sys.platform == "darwin" else None, + # macOS: the architecture of the Python building it — one build on an + # Apple-silicon runner, one on an Intel runner (not a universal2 app: + # numba, scipy etc. ship separate wheels per architecture). + target_arch=None, ) coll = COLLECT(exe, a.binaries, a.datas, name="MEA-NAP", upx=False) diff --git a/pyproject.toml b/pyproject.toml index ee05d02e..8c6b7f76 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,11 @@ dependencies = [ # (randmio_und_signed), ~28x faster than the pure-Python fallback; the # code runs without numba too, just slower (see pipeline/null_models.py). "numpy>=1.26,<2.5", - "numba>=0.60", + # Intel Macs: numba 0.62 / llvmlite 0.45 are the last with Intel-Mac + # wheels (newer ones would compile LLVM from source), and they bring + # numpy<2.4 with them. Every other platform keeps the newest. + "numba>=0.60; sys_platform != 'darwin' or platform_machine != 'x86_64'", + "numba>=0.60,<0.63; sys_platform == 'darwin' and platform_machine == 'x86_64'", "psutil>=5.9", # RAM/CPU-aware parallel worker sizing (pipeline/parallel.py) "scipy>=1.13", "matplotlib>=3.8", diff --git a/uv.lock b/uv.lock index 83d398f6..3a791a78 100644 --- a/uv.lock +++ b/uv.lock @@ -4,16 +4,20 @@ requires-python = ">=3.11" resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", "python_full_version >= '3.14' and sys_platform == 'emscripten'", - "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'darwin'", + "(python_full_version >= '3.14' and platform_machine != 'x86_64' and sys_platform == 'darwin') or (python_full_version >= '3.14' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32')", "python_full_version == '3.13.*' and sys_platform == 'win32'", "python_full_version == '3.12.*' and sys_platform == 'win32'", "python_full_version == '3.13.*' and sys_platform == 'emscripten'", "python_full_version == '3.12.*' and sys_platform == 'emscripten'", - "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and platform_machine == 'x86_64' and sys_platform == 'darwin'", + "(python_full_version == '3.13.*' and platform_machine != 'x86_64' and sys_platform == 'darwin') or (python_full_version == '3.13.*' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32')", + "python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'darwin'", + "(python_full_version == '3.12.*' and platform_machine != 'x86_64' and sys_platform == 'darwin') or (python_full_version == '3.12.*' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32')", "python_full_version < '3.12' and sys_platform == 'win32'", "python_full_version < '3.12' and sys_platform == 'emscripten'", - "python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version < '3.12' and platform_machine == 'x86_64' and sys_platform == 'darwin'", + "(python_full_version < '3.12' and platform_machine != 'x86_64' and sys_platform == 'darwin') or (python_full_version < '3.12' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32')", ] [[package]] @@ -210,7 +214,8 @@ name = "contourpy" version = "1.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy" }, + { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine == 'x86_64' and sys_platform == 'darwin'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } wheels = [ @@ -480,7 +485,8 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "interface-meta" }, { name = "narwhals" }, - { name = "numpy" }, + { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine == 'x86_64' and sys_platform == 'darwin'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, { name = "pandas" }, { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, @@ -497,7 +503,8 @@ name = "h5py" version = "3.16.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy" }, + { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine == 'x86_64' and sys_platform == 'darwin'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/db/33/acd0ce6863b6c0d7735007df01815403f5589a21ff8c2e1ee2587a38f548/h5py-3.16.0.tar.gz", hash = "sha256:a0dbaad796840ccaa67a4c144a0d0c8080073c34c76d5a6941d6818678ef2738", size = 446526, upload-time = "2026-03-06T13:49:08.07Z" } wheels = [ @@ -549,7 +556,8 @@ version = "0.8.44" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "joblib" }, - { name = "numpy" }, + { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine == 'x86_64' and sys_platform == 'darwin'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, { name = "scikit-learn" }, { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, @@ -728,10 +736,41 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0a/dd/8050c947d435c8d4bc94e3252f4d8bb8a76cfb424f043a8680be637a57f1/kiwisolver-1.5.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:59cd8683f575d96df5bb48f6add94afc055012c29e28124fcae2b63661b9efb1", size = 73558, upload-time = "2026-03-09T13:15:52.112Z" }, ] +[[package]] +name = "llvmlite" +version = "0.45.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'darwin'", + "python_full_version == '3.13.*' and platform_machine == 'x86_64' and sys_platform == 'darwin'", + "python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'darwin'", + "python_full_version < '3.12' and platform_machine == 'x86_64' and sys_platform == 'darwin'", +] +sdist = { url = "https://files.pythonhosted.org/packages/99/8d/5baf1cef7f9c084fb35a8afbde88074f0d6a727bc63ef764fe0e7543ba40/llvmlite-0.45.1.tar.gz", hash = "sha256:09430bb9d0bb58fc45a45a57c7eae912850bedc095cd0810a57de109c69e1c32", size = 185600, upload-time = "2025-10-01T17:59:52.046Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/ad/9bdc87b2eb34642c1cfe6bcb4f5db64c21f91f26b010f263e7467e7536a3/llvmlite-0.45.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:60f92868d5d3af30b4239b50e1717cb4e4e54f6ac1c361a27903b318d0f07f42", size = 43043526, upload-time = "2025-10-01T18:03:15.051Z" }, + { url = "https://files.pythonhosted.org/packages/e2/7c/82cbd5c656e8991bcc110c69d05913be2229302a92acb96109e166ae31fb/llvmlite-0.45.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:28e763aba92fe9c72296911e040231d486447c01d4f90027c8e893d89d49b20e", size = 43043524, upload-time = "2025-10-01T18:03:30.666Z" }, + { url = "https://files.pythonhosted.org/packages/1d/e2/c185bb7e88514d5025f93c6c4092f6120c6cea8fe938974ec9860fb03bbb/llvmlite-0.45.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:d9ea9e6f17569a4253515cc01dade70aba536476e3d750b2e18d81d7e670eb15", size = 43043524, upload-time = "2025-10-01T18:03:43.249Z" }, +] + [[package]] name = "llvmlite" version = "0.48.0" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "(python_full_version >= '3.14' and platform_machine != 'x86_64' and sys_platform == 'darwin') or (python_full_version >= '3.14' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32')", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "(python_full_version == '3.13.*' and platform_machine != 'x86_64' and sys_platform == 'darwin') or (python_full_version == '3.13.*' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32')", + "(python_full_version == '3.12.*' and platform_machine != 'x86_64' and sys_platform == 'darwin') or (python_full_version == '3.12.*' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32')", + "python_full_version < '3.12' and sys_platform == 'win32'", + "python_full_version < '3.12' and sys_platform == 'emscripten'", + "(python_full_version < '3.12' and platform_machine != 'x86_64' and sys_platform == 'darwin') or (python_full_version < '3.12' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32')", +] sdist = { url = "https://files.pythonhosted.org/packages/dc/a0/acc8ffcd5bdc63df0097e22c719bfcd61b604358343089313a8aebbb24ab/llvmlite-0.48.0.tar.gz", hash = "sha256:543b19f9ef8f3c7c60d1468191e4ee1b1537bf9f8a3d56f64c0ddd98de92edd2", size = 184016, upload-time = "2026-07-02T20:20:05.308Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/9a/55/595981f14fbae9ba966feb12af552b1fe69889e44e64ac883a731ed335e0/llvmlite-0.48.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:56a7e24607d3f02d7b1bae8d29c7e1e423d53143d68b072999777f19678fe77b", size = 40480651, upload-time = "2026-07-01T18:41:18.438Z" }, @@ -777,7 +816,8 @@ dependencies = [ { name = "cycler" }, { name = "fonttools" }, { name = "kiwisolver" }, - { name = "numpy" }, + { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine == 'x86_64' and sys_platform == 'darwin'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, { name = "packaging" }, { name = "pillow" }, { name = "pyparsing" }, @@ -841,8 +881,10 @@ dependencies = [ { name = "matplotlib" }, { name = "natsort" }, { name = "networkx" }, - { name = "numba" }, - { name = "numpy" }, + { name = "numba", version = "0.62.1", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine == 'x86_64' and sys_platform == 'darwin'" }, + { name = "numba", version = "0.66.0", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, + { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine == 'x86_64' and sys_platform == 'darwin'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, { name = "openpyxl" }, { name = "pandas" }, { name = "pillow" }, @@ -890,7 +932,8 @@ requires-dist = [ { name = "mountainsort5", marker = "extra == 'sorting'", specifier = ">=0.5" }, { name = "natsort", specifier = ">=8.4" }, { name = "networkx", specifier = ">=3.3" }, - { name = "numba", specifier = ">=0.60" }, + { name = "numba", marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'", specifier = ">=0.60" }, + { name = "numba", marker = "platform_machine == 'x86_64' and sys_platform == 'darwin'", specifier = ">=0.60,<0.63" }, { name = "numpy", specifier = ">=1.26,<2.5" }, { name = "oasis-deconv", marker = "extra == 'oasis'", specifier = ">=0.3.2" }, { name = "openpyxl", specifier = ">=3.1" }, @@ -957,7 +1000,8 @@ name = "neo" version = "0.14.5" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy" }, + { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine == 'x86_64' and sys_platform == 'darwin'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, { name = "packaging" }, { name = "quantities" }, ] @@ -975,13 +1019,48 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" }, ] +[[package]] +name = "numba" +version = "0.62.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'darwin'", + "python_full_version == '3.13.*' and platform_machine == 'x86_64' and sys_platform == 'darwin'", + "python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'darwin'", + "python_full_version < '3.12' and platform_machine == 'x86_64' and sys_platform == 'darwin'", +] +dependencies = [ + { name = "llvmlite", version = "0.45.1", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine == 'x86_64' and sys_platform == 'darwin'" }, + { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine == 'x86_64' and sys_platform == 'darwin'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/20/33dbdbfe60e5fd8e3dbfde299d106279a33d9f8308346022316781368591/numba-0.62.1.tar.gz", hash = "sha256:7b774242aa890e34c21200a1fc62e5b5757d5286267e71103257f4e2af0d5161", size = 2749817, upload-time = "2025-09-29T10:46:31.551Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dd/5f/8b3491dd849474f55e33c16ef55678ace1455c490555337899c35826836c/numba-0.62.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:f43e24b057714e480fe44bc6031de499e7cf8150c63eb461192caa6cc8530bc8", size = 2684279, upload-time = "2025-09-29T10:43:37.213Z" }, + { url = "https://files.pythonhosted.org/packages/5e/fa/30fa6873e9f821c0ae755915a3ca444e6ff8d6a7b6860b669a3d33377ac7/numba-0.62.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:1b743b32f8fa5fff22e19c2e906db2f0a340782caf024477b97801b918cf0494", size = 2685346, upload-time = "2025-09-29T10:43:43.677Z" }, + { url = "https://files.pythonhosted.org/packages/22/76/501ea2c07c089ef1386868f33dff2978f43f51b854e34397b20fc55e0a58/numba-0.62.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:b72489ba8411cc9fdcaa2458d8f7677751e94f0109eeb53e5becfdc818c64afb", size = 2685766, upload-time = "2025-09-29T10:43:49.161Z" }, +] + [[package]] name = "numba" version = "0.66.0" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "(python_full_version >= '3.14' and platform_machine != 'x86_64' and sys_platform == 'darwin') or (python_full_version >= '3.14' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32')", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "(python_full_version == '3.13.*' and platform_machine != 'x86_64' and sys_platform == 'darwin') or (python_full_version == '3.13.*' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32')", + "(python_full_version == '3.12.*' and platform_machine != 'x86_64' and sys_platform == 'darwin') or (python_full_version == '3.12.*' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32')", + "python_full_version < '3.12' and sys_platform == 'win32'", + "python_full_version < '3.12' and sys_platform == 'emscripten'", + "(python_full_version < '3.12' and platform_machine != 'x86_64' and sys_platform == 'darwin') or (python_full_version < '3.12' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32')", +] dependencies = [ - { name = "llvmlite" }, - { name = "numpy" }, + { name = "llvmlite", version = "0.48.0", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ae/a0/570e3dc53e5602b49108f62a13e529f1eec8bfc7ef37d49c825924dcf546/numba-0.66.0.tar.gz", hash = "sha256:b900e63a0e26c05ea9a6d5a3a5a0a177cb64c5011887bf43edb8c3ed2c38d363", size = 2806181, upload-time = "2026-07-01T23:12:46.36Z" } wheels = [ @@ -1013,7 +1092,8 @@ version = "0.15.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "deprecated" }, - { name = "numpy" }, + { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine == 'x86_64' and sys_platform == 'darwin'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/63/fc/bb532969eb8236984ba65e4f0079a7da885b8ac0ce1f0835decbb3938a62/numcodecs-0.15.1.tar.gz", hash = "sha256:eeed77e4d6636641a2cc605fbc6078c7a8f2cc40f3dfa2b3f61e52e6091b04ff", size = 6267275, upload-time = "2025-02-10T10:23:33.254Z" } wheels = [ @@ -1031,10 +1111,52 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c3/32/233e5ede6568bdb044e6f99aaa9fa39827ff3109c6487fc137315f733586/numcodecs-0.15.1-cp313-cp313-win_amd64.whl", hash = "sha256:a4f7bdb26f1b34423cb56d48e75821223be38040907c9b5954eeb7463e7eb03c", size = 831955, upload-time = "2025-02-10T10:23:30.601Z" }, ] +[[package]] +name = "numpy" +version = "2.3.5" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'darwin'", + "python_full_version == '3.13.*' and platform_machine == 'x86_64' and sys_platform == 'darwin'", + "python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'darwin'", + "python_full_version < '3.12' and platform_machine == 'x86_64' and sys_platform == 'darwin'", +] +sdist = { url = "https://files.pythonhosted.org/packages/76/65/21b3bc86aac7b8f2862db1e808f1ea22b028e30a225a34a5ede9bf8678f2/numpy-2.3.5.tar.gz", hash = "sha256:784db1dcdab56bf0517743e746dfb0f885fc68d948aba86eeec2cba234bdf1c0", size = 20584950, upload-time = "2025-11-16T22:52:42.067Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/77/84dd1d2e34d7e2792a236ba180b5e8fcc1e3e414e761ce0253f63d7f572e/numpy-2.3.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:de5672f4a7b200c15a4127042170a694d4df43c992948f5e1af57f0174beed10", size = 17034641, upload-time = "2025-11-16T22:49:19.336Z" }, + { url = "https://files.pythonhosted.org/packages/5c/bb/35ef04afd567f4c989c2060cde39211e4ac5357155c1833bcd1166055c61/numpy-2.3.5-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:872a5cf366aec6bb1147336480fef14c9164b154aeb6542327de4970282cd2f5", size = 6893148, upload-time = "2025-11-16T22:49:27.549Z" }, + { url = "https://files.pythonhosted.org/packages/44/37/e669fe6cbb2b96c62f6bbedc6a81c0f3b7362f6a59230b23caa673a85721/numpy-2.3.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:74ae7b798248fe62021dbf3c914245ad45d1a6b0cb4a29ecb4b31d0bfbc4cc3e", size = 16733873, upload-time = "2025-11-16T22:49:49.84Z" }, + { url = "https://files.pythonhosted.org/packages/6d/9c/1ca85fb86708724275103b81ec4cf1ac1d08f465368acfc8da7ab545bdae/numpy-2.3.5-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:3101e5177d114a593d79dd79658650fe28b5a0d8abeb8ce6f437c0e6df5be1a4", size = 6628559, upload-time = "2025-11-16T22:49:57.371Z" }, + { url = "https://files.pythonhosted.org/packages/db/69/9cde09f36da4b5a505341180a3f2e6fadc352fd4d2b7096ce9778db83f1a/numpy-2.3.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d0f23b44f57077c1ede8c5f26b30f706498b4862d3ff0a7298b8411dd2f043ff", size = 16728251, upload-time = "2025-11-16T22:50:19.013Z" }, + { url = "https://files.pythonhosted.org/packages/a4/b4/ee5bb2537fb9430fd2ef30a616c3672b991a4129bb1c7dcc42aa0abbe5d7/numpy-2.3.5-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:afaffc4393205524af9dfa400fa250143a6c3bc646c08c9f5e25a9f4b4d6a903", size = 6622990, upload-time = "2025-11-16T22:50:26.47Z" }, + { url = "https://files.pythonhosted.org/packages/13/cb/71744144e13389d577f867f745b7df2d8489463654a918eea2eeb166dfc9/numpy-2.3.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:414802f3b97f3c1eef41e530aaba3b3c1620649871d8cb38c6eaff034c2e16bd", size = 16827292, upload-time = "2025-11-16T22:50:47.715Z" }, + { url = "https://files.pythonhosted.org/packages/80/ae/429bacace5ccad48a14c4ae5332f6aa8ab9f69524193511d60ccdfdc65fa/numpy-2.3.5-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:30bc11310e8153ca664b14c5f1b73e94bd0503681fcf136a163de856f3a50139", size = 6721275, upload-time = "2025-11-16T22:50:56.794Z" }, + { url = "https://files.pythonhosted.org/packages/ba/97/1a914559c19e32d6b2e233cf9a6a114e67c856d35b1d6babca571a3e880f/numpy-2.3.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:bf06bc2af43fa8d32d30fae16ad965663e966b1a3202ed407b84c989c3221e82", size = 16735706, upload-time = "2025-11-16T22:51:19.558Z" }, + { url = "https://files.pythonhosted.org/packages/ce/0e/0698378989bb0ac5f1660c81c78ab1fe5476c1a521ca9ee9d0710ce54099/numpy-2.3.5-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:2dcd0808a421a482a080f89859a18beb0b3d1e905b81e617a188bd80422d62e9", size = 6626603, upload-time = "2025-11-16T22:51:27Z" }, + { url = "https://files.pythonhosted.org/packages/17/e1/f6a721234ebd4d87084cfa68d081bcba2f5cfe1974f7de4e0e8b9b2a2ba1/numpy-2.3.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:17531366a2e3a9e30762c000f2c43a9aaa05728712e25c11ce1dbe700c53ad41", size = 16834503, upload-time = "2025-11-16T22:51:50.443Z" }, + { url = "https://files.pythonhosted.org/packages/2e/3b/78aebf345104ec50dd50a4d06ddeb46a9ff5261c33bcc58b1c4f12f85ec2/numpy-2.3.5-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:cc0a57f895b96ec78969c34f682c602bf8da1a0270b09bc65673df2e7638ec20", size = 6724502, upload-time = "2025-11-16T22:51:58.584Z" }, + { url = "https://files.pythonhosted.org/packages/c6/65/f9dea8e109371ade9c782b4e4756a82edf9d3366bca495d84d79859a0b79/numpy-2.3.5-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:f0963b55cdd70fad460fa4c1341f12f976bb26cb66021a5580329bd498988310", size = 16910689, upload-time = "2025-11-16T22:52:23.247Z" }, + { url = "https://files.pythonhosted.org/packages/a3/2f/37eeb9014d9c8b3e9c55bc599c68263ca44fdbc12a93e45a21d1d56df737/numpy-2.3.5-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:2feae0d2c91d46e59fcd62784a3a83b3fb677fead592ce51b5a6fbb4f95965ff", size = 6801770, upload-time = "2025-11-16T22:52:31.421Z" }, +] + [[package]] name = "numpy" version = "2.4.6" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "(python_full_version >= '3.14' and platform_machine != 'x86_64' and sys_platform == 'darwin') or (python_full_version >= '3.14' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32')", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "(python_full_version == '3.13.*' and platform_machine != 'x86_64' and sys_platform == 'darwin') or (python_full_version == '3.13.*' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32')", + "(python_full_version == '3.12.*' and platform_machine != 'x86_64' and sys_platform == 'darwin') or (python_full_version == '3.12.*' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32')", + "python_full_version < '3.12' and sys_platform == 'win32'", + "python_full_version < '3.12' and sys_platform == 'emscripten'", + "(python_full_version < '3.12' and platform_machine != 'x86_64' and sys_platform == 'darwin') or (python_full_version < '3.12' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32')", +] sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/b3/49/ec46835a70be8fa6446c495126ac84fdb28cb2558e1620ffb87a10c8b64c/numpy-2.4.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4", size = 16969194, upload-time = "2026-05-18T23:33:13.503Z" }, @@ -1116,7 +1238,8 @@ version = "0.3.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "matplotlib" }, - { name = "numpy" }, + { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine == 'x86_64' and sys_platform == 'darwin'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] @@ -1162,7 +1285,8 @@ name = "pandas" version = "3.0.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy" }, + { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine == 'x86_64' and sys_platform == 'darwin'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, { name = "python-dateutil" }, { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, ] @@ -1216,7 +1340,8 @@ name = "patsy" version = "1.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy" }, + { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine == 'x86_64' and sys_platform == 'darwin'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, { name = "packaging" }, ] sdist = { url = "https://files.pythonhosted.org/packages/08/e3/950e513affdfe1c1864397b4f40d6ed06ec1b54f608e462c27f151dd72df/patsy-1.0.3.tar.gz", hash = "sha256:79ebf4c93ff4d296e58a9d5be2b2ee31bd49d737cf11d70ffbd8a44b2de42e65", size = 399992, upload-time = "2026-08-29T21:35:32.258Z" } @@ -1334,7 +1459,8 @@ name = "probeinterface" version = "0.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy" }, + { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine == 'x86_64' and sys_platform == 'darwin'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, { name = "packaging" }, { name = "requests" }, ] @@ -1376,7 +1502,8 @@ name = "pybaselines" version = "1.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy" }, + { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine == 'x86_64' and sys_platform == 'darwin'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] @@ -1636,7 +1763,8 @@ version = "0.14.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama" }, - { name = "numpy" }, + { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine == 'x86_64' and sys_platform == 'darwin'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/32/36/4c242f81fdcbfa4fb62a5645f6af79191f4097a0577bd5460c24f19cc4ef/pyqtgraph-0.14.0-py3-none-any.whl", hash = "sha256:7abb7c3e17362add64f8711b474dffac5e7b0e9245abdf992e9a44119b7aa4f5", size = 1924755, upload-time = "2025-11-16T19:43:22.251Z" }, @@ -1689,7 +1817,8 @@ name = "pywavelets" version = "1.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy" }, + { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine == 'x86_64' and sys_platform == 'darwin'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5a/75/50581633d199812205ea8cdd0f6d52f12a624886b74bf1486335b67f01ff/pywavelets-1.9.0.tar.gz", hash = "sha256:148d12203377772bea452a59211d98649c8ee4a05eff019a9021853a36babdc8", size = 3938340, upload-time = "2025-08-04T16:20:04.978Z" } wheels = [ @@ -1757,7 +1886,8 @@ name = "quantities" version = "0.16.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy" }, + { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine == 'x86_64' and sys_platform == 'darwin'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/aa/50/19194d2cf428239ecbda876d81bbfddb76697a248c5a7478ae83d0b98fee/quantities-0.16.4.tar.gz", hash = "sha256:f5b6deb74f6ed814ec56b204dcf7b5d3c526456157de48e5a78681064f98bf36", size = 100726, upload-time = "2026-01-16T10:43:48.858Z" } wheels = [ @@ -1811,7 +1941,8 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "joblib" }, { name = "narwhals" }, - { name = "numpy" }, + { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine == 'x86_64' and sys_platform == 'darwin'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "threadpoolctl" }, @@ -1856,7 +1987,8 @@ version = "0.0.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "matplotlib" }, - { name = "numpy" }, + { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine == 'x86_64' and sys_platform == 'darwin'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/2b/75/dcaab14f7e7e1522e9677bc4efb48d41d645ab745fb553377a017991a9cb/sciplotlib-0.0.7.tar.gz", hash = "sha256:e25d8b2bf4ba250057535314da037545c02e64c1d3c16ec717334513c64963db", size = 52276, upload-time = "2023-12-13T18:39:06.637Z" } wheels = [ @@ -1870,10 +2002,12 @@ source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version < '3.12' and sys_platform == 'win32'", "python_full_version < '3.12' and sys_platform == 'emscripten'", - "python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version < '3.12' and platform_machine == 'x86_64' and sys_platform == 'darwin'", + "(python_full_version < '3.12' and platform_machine != 'x86_64' and sys_platform == 'darwin') or (python_full_version < '3.12' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32')", ] dependencies = [ - { name = "numpy", marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12' and platform_machine == 'x86_64' and sys_platform == 'darwin'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.12' and platform_machine != 'x86_64') or (python_full_version < '3.12' and sys_platform != 'darwin')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } wheels = [ @@ -1946,16 +2080,20 @@ source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", "python_full_version >= '3.14' and sys_platform == 'emscripten'", - "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'darwin'", + "(python_full_version >= '3.14' and platform_machine != 'x86_64' and sys_platform == 'darwin') or (python_full_version >= '3.14' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32')", "python_full_version == '3.13.*' and sys_platform == 'win32'", "python_full_version == '3.12.*' and sys_platform == 'win32'", "python_full_version == '3.13.*' and sys_platform == 'emscripten'", "python_full_version == '3.12.*' and sys_platform == 'emscripten'", - "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and platform_machine == 'x86_64' and sys_platform == 'darwin'", + "(python_full_version == '3.13.*' and platform_machine != 'x86_64' and sys_platform == 'darwin') or (python_full_version == '3.13.*' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32')", + "python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'darwin'", + "(python_full_version == '3.12.*' and platform_machine != 'x86_64' and sys_platform == 'darwin') or (python_full_version == '3.12.*' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32')", ] dependencies = [ - { name = "numpy", marker = "python_full_version >= '3.12'" }, + { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12' and platform_machine == 'x86_64' and sys_platform == 'darwin'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.12' and platform_machine != 'x86_64') or (python_full_version >= '3.12' and sys_platform != 'darwin')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a7/25/c2700dfaf6442b4effaa91af24ebce5dc9d31bb4a69706313aae70d72cd0/scipy-1.18.0.tar.gz", hash = "sha256:67b2ad2ad54c72ca6d04975a9b2df8c3638c34ddd5b28738e94fc2b57929d378", size = 30774447, upload-time = "2026-06-19T15:01:43.456Z" } wheels = [ @@ -2007,7 +2145,8 @@ version = "0.13.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "matplotlib" }, - { name = "numpy" }, + { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine == 'x86_64' and sys_platform == 'darwin'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, { name = "pandas" }, ] sdist = { url = "https://files.pythonhosted.org/packages/86/59/a451d7420a77ab0b98f7affa3a1d78a313d2f7281a57afb1a34bae8ab412/seaborn-0.13.2.tar.gz", hash = "sha256:93e60a40988f4d65e9f4885df477e2fdaff6b73a9ded434c1ab356dd57eefff7", size = 1457696, upload-time = "2024-01-25T13:21:52.551Z" } @@ -2040,7 +2179,8 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "neo" }, { name = "numcodecs" }, - { name = "numpy" }, + { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine == 'x86_64' and sys_platform == 'darwin'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, { name = "packaging" }, { name = "probeinterface" }, { name = "pydantic" }, @@ -2059,7 +2199,8 @@ version = "0.15.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "formulaic" }, - { name = "numpy" }, + { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine == 'x86_64' and sys_platform == 'darwin'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, { name = "packaging" }, { name = "pandas" }, { name = "patsy" }, @@ -2326,7 +2467,8 @@ dependencies = [ { name = "asciitree" }, { name = "fasteners", marker = "sys_platform != 'emscripten'" }, { name = "numcodecs" }, - { name = "numpy" }, + { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine == 'x86_64' and sys_platform == 'darwin'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/da/1d/01cf9e3ab2d85190278efc3fca9f68563de35ae30ee59e7640e3af98abe3/zarr-2.18.7.tar.gz", hash = "sha256:b2b8f66f14dac4af66b180d2338819981b981f70e196c9a66e6bfaa9e59572f5", size = 3604558, upload-time = "2025-04-09T07:59:28.482Z" } wheels = [ From fbc6ebda7d39ab52d623b2e95928a250e613b46e Mon Sep 17 00:00:00 2001 From: timsit Date: Wed, 21 Oct 2026 19:38:14 +0100 Subject: [PATCH 6/7] Desktop app docs: Intel Mac download Co-Authored-By: Claude Opus 5.5 --- docs/python/installation.md | 13 +++++++------ python/DESKTOP_APP_PLAN.md | 11 ++++++++++- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/docs/python/installation.md b/docs/python/installation.md index 678538d7..d0581e9c 100644 --- a/docs/python/installation.md +++ b/docs/python/installation.md @@ -3,10 +3,10 @@ There are two ways to get the Python version of MEA-NAP: - **The desktop app** — download one file, double-click, done. No Python, no - terminal. For Windows and Apple-silicon Macs (M1 or later). + terminal. For Windows and Macs (Apple silicon or Intel). - **From the source code** — a clone of the repository and a few commands in a - terminal. For Linux and Intel Macs, for the newest code between releases, - and for anyone who wants to change MEA-NAP itself. + terminal. For Linux, for the newest code between releases, and for anyone + who wants to change MEA-NAP itself. Both run the same pipeline with the same pinned libraries, so they give the same results. @@ -21,10 +21,11 @@ on GitHub, under **Assets**: |---|---| | Windows 10 or 11 (64-bit) | `MEA-NAP--Windows-x64-Setup.exe` | | Mac with Apple silicon (M1, M2, M3, M4 …) | `MEA-NAP--macOS-arm64.dmg` | +| Mac with an Intel processor | `MEA-NAP--macOS-Intel.dmg` | Not sure which Mac you have? Apple menu → **About This Mac**: "Chip: Apple M…" -means Apple silicon; "Processor: Intel" means an Intel Mac, which the app -doesn't support — install from source instead. +means Apple silicon; "Processor: … Intel …" means an Intel Mac. Either needs +macOS 12 (Monterey) or later. The download is about 150 MB (about 700 MB once installed): it carries its own Python and every library MEA-NAP uses, including OASIS for CAT-NAP and the spike @@ -50,7 +51,7 @@ apps → MEA-NAP → Uninstall**. ::::{tab-item} macOS -1. Double-click `MEA-NAP-…-macOS-arm64.dmg`, then drag **MEA-NAP** onto +1. Double-click the `MEA-NAP-…-macOS-….dmg` you downloaded, then drag **MEA-NAP** onto the **Applications** folder in the window that opens. 2. Open MEA-NAP from Applications (or Launchpad). The first time, macOS says it **"could not verify MEA-NAP is free of malware"** (older macOS: "cannot diff --git a/python/DESKTOP_APP_PLAN.md b/python/DESKTOP_APP_PLAN.md index 950cd2f8..9ad78d62 100644 --- a/python/DESKTOP_APP_PLAN.md +++ b/python/DESKTOP_APP_PLAN.md @@ -9,7 +9,7 @@ it, and gets the MEA-NAP GUI. Today's route — git clone, `uv sync`, | Question | Decision | |---|---| | Approach | **Frozen app** (PyInstaller, one-folder build): Python and every dependency inside. No network needed to install or run. | -| Platforms | **Windows x64** and **macOS Apple silicon** (arm64). Not Intel Mac, not Linux (Linux users manage with uv). | +| Platforms | **Windows x64** and **macOS Apple silicon** (arm64); **macOS Intel** added 2026-10-21 on request. Not Linux (Linux users manage with uv). | | Signing | **Unsigned for now.** Docs and the release page explain the Gatekeeper / SmartScreen bypass step by step. Signing can come later without changing the build. | | Extras inside | **OASIS** (CAT-NAP denoising; without it results silently differ) and **spike sorting** (SpikeInterface + MountainSort5 + hdbscan). | | Left out | CAT-NAP **cell tracking** (needs `roicat` → PyTorch, not a declared dependency); **Kilosort**; **launching MATLAB / old MATLAB-only releases**; switching between versions in place. | @@ -77,6 +77,15 @@ the package and, on Windows, no console. Each of these breaks something today: spikeinterface until a run needs them. - **Unsigned macOS + quarantine**: on recent macOS the bypass is System Settings → Privacy & Security → "Open Anyway". Must be tested on a real Mac. +- **Intel Mac pins.** numba 0.62 / llvmlite 0.45 are the last with Intel-Mac + wheels, so `pyproject.toml` caps numba there (a platform marker; uv locks a + separate numba/llvmlite/numpy for Intel Macs only). Intel Mac therefore + stays on numba 0.62 + numpy 2.3 for good — fine while those keep working + with our other pins; if a future scipy/pandas needs numpy ≥ 2.4, the Intel + build has to drop numba (pure-Python fallback, slower step 4) or be retired. + GitHub's `macos-15-intel` runner is its last Intel image (until ~2027). +- Source-only extensions (isosplit6; OASIS on Intel) are compiled in CI with + `MACOSX_DEPLOYMENT_TARGET=12.0`, or they'd only load on the runner's macOS. - No macOS/Windows machine locally: real-device testing depends on the user. ## Status From f99d3c2d2a85ac1192a692bb9c000cec0cd80079 Mon Sep 17 00:00:00 2001 From: timsit Date: Wed, 21 Oct 2026 20:55:05 +0100 Subject: [PATCH 7/7] Desktop app docs: getting past Chrome's download warning Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01X78uDckMcD2xCkAo9Hj7g8 --- docs/python/installation.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/docs/python/installation.md b/docs/python/installation.md index d0581e9c..616f08b2 100644 --- a/docs/python/installation.md +++ b/docs/python/installation.md @@ -31,6 +31,23 @@ The download is about 150 MB (about 700 MB once installed): it carries its own P and every library MEA-NAP uses, including OASIS for CAT-NAP and the spike sorters. +**If your browser warns about the download.** Chrome in particular may say +the file is *suspicious*, *dangerous* or *not commonly downloaded*, and hold it +back. It says this about any program that isn't signed with a paid +certificate and that few people have downloaded yet — and each new release +starts that count again. To keep the file in Chrome: + +1. Open the downloads list: the download icon at the top right, or + **Ctrl+J** (Windows) / **⌘+Shift+J** (Mac). +2. Find the MEA-NAP file and choose **Keep** (or **Download suspicious + file**). +3. Confirm with **Keep anyway** (or **Download anyway**). + +In Edge, it's the **…** beside the download → **Keep** → **Show more** → +**Keep anyway**. Only download MEA-NAP from the +[GitHub releases page](https://github.com/SAND-Lab/MEA-NAP/releases) — that is +what makes it safe to click through. + :::::{tab-set} ::::{tab-item} Windows