diff --git a/README.md b/README.md index 8ec1924..86b45bd 100644 --- a/README.md +++ b/README.md @@ -64,16 +64,30 @@ the command at `~/.local/bin/sonpipe`. On Windows the venv lives under paste into MATLAB. Run `./install.sh --help` for options (custom prefix, Python, installing from PyPI, etc.). +**Updating.** After a `git pull`, re-run `./install.sh` (or `./update.sh`) — it +reuses the existing environment and upgrades the sonpipe package in place, which +is fast and doesn't re-download `sonpy`. Use `./install.sh --recreate` to force +a clean rebuild. + > **Python version.** CED ships `sonpy` wheels for **Python 3.14 on Linux and > macOS** (and Python 3.9–3.14 on Windows). The installer prefers a > `python3.14` interpreter; if `sonpy` cannot be imported it tells you to > install 3.14 and re-run with `--python "$(command -v python3.14)"`. > -> **Apple Silicon.** CED's current macOS `sonpy` wheel contains an x86_64-only -> binary (despite its `universal2` label), so on Apple Silicon Macs you need an -> **x86_64 Python running under Rosetta 2** until CED ships a native arm64 -> build. Everything else (the sonpipe CLI itself, the MATLAB package, and the -> fake-sonpy test suites) runs natively on arm64. +> **Apple Silicon.** CED's macOS `sonpy` is x86_64-only (despite its +> `universal2` label) **and** is linked against the official **python.org** +> framework build — so it will not load under Homebrew or `uv` Python. On an +> Apple Silicon Mac: +> +> 1. Install **Python 3.14 from [python.org](https://www.python.org/downloads/macos/)** +> (the universal2 installer). +> 2. Run `./install.sh` — it detects Apple Silicon, finds that framework Python +> automatically, builds an **x86_64** venv under Rosetta 2, and installs the +> `sonpipe` command as an `arch -x86_64` wrapper so it runs correctly even +> when launched from MATLAB. +> +> Everything else (the CLI logic, the MATLAB package, and the fake-sonpy test +> suites) runs natively on arm64. ### Manual: pip diff --git a/install.sh b/install.sh index 3aa720f..81931f0 100755 --- a/install.sh +++ b/install.sh @@ -3,26 +3,33 @@ # sonpipe installer for Linux and macOS. # # Installs the sonpipe command-line tool (and CED's sonpy) into an isolated -# Python virtual environment, and symlinks the `sonpipe` command into your +# Python virtual environment, and puts the `sonpipe` command in your # ~/.local/bin so it is on your PATH. Then verifies the install and prints the # line you need to wire sonpipe into MATLAB. # +# Re-run it any time to UPDATE: if the environment already exists it is reused +# and the sonpipe package is upgraded in place (fast; no venv rebuild). Use +# --recreate to force a clean rebuild. +# # venv: ~/.local/share/sonpipe/venv (isolated; holds sonpy + numpy) -# command: ~/.local/bin/sonpipe (symlink onto your PATH) +# command: ~/.local/bin/sonpipe (onto your PATH) # # Usage: -# ./install.sh [options] +# ./install.sh [options] # install, or update if already installed # # Options: # --prefix DIR Base install prefix (default: ~/.local); the venv goes in # DIR/share/sonpipe/venv and the command in DIR/bin. # --venv DIR Override the virtual environment location. -# --bin-dir DIR Override where the `sonpipe` command is linked. +# --bin-dir DIR Override where the `sonpipe` command is placed. # --python PY Python interpreter to build the venv with (default: auto, # prefers python3.14 for sonpy wheel availability). # --pypi Install from PyPI (default when not run inside the repo). # --source PATH Install from this path or pip spec (default: the repo). -# --no-symlink Do not create the ~/.local/bin symlink. +# --update Update the existing environment in place (the default when +# one exists). +# --recreate Delete and rebuild the environment from scratch. +# --no-symlink Do not create the ~/.local/bin command. # -h, --help Show this help. # # Note: CED ships sonpy Linux/macOS wheels only for Python 3.14, so this script @@ -37,11 +44,29 @@ BIN_DIR="" PYTHON="" SOURCE="" SYMLINK=1 +RECREATE=0 log() { printf '\033[1;34m[sonpipe]\033[0m %s\n' "$*"; } warn() { printf '\033[1;33m[sonpipe] WARNING:\033[0m %s\n' "$*" >&2; } err() { printf '\033[1;31m[sonpipe] ERROR:\033[0m %s\n' "$*" >&2; exit 1; } +# Print a PATH-setup instruction appropriate to the user's login shell. +path_hint() { + bindir="$1" + case "$(basename "${SHELL:-}")" in + zsh) + warn " echo 'export PATH=\"$bindir:\$PATH\"' >> ~/.zshrc && source ~/.zshrc" ;; + bash) + # macOS login shells read ~/.bash_profile; most Linux shells read ~/.bashrc. + if [ "$(uname -s)" = "Darwin" ]; then rc="~/.bash_profile"; else rc="~/.bashrc"; fi + warn " echo 'export PATH=\"$bindir:\$PATH\"' >> $rc && source $rc" ;; + fish) + warn " fish_add_path $bindir" ;; + *) + warn " add to your shell profile: export PATH=\"$bindir:\$PATH\"" ;; + esac +} + while [ $# -gt 0 ]; do case "$1" in --prefix) PREFIX="$2"; shift 2 ;; @@ -51,7 +76,9 @@ while [ $# -gt 0 ]; do --source) SOURCE="$2"; shift 2 ;; --pypi) SOURCE="sonpipe"; shift ;; --no-symlink) SYMLINK=0; shift ;; - -h|--help) sed -n '2,38p' "$0" | sed 's/^#\{0,1\} \{0,1\}//'; exit 0 ;; + --recreate) RECREATE=1; shift ;; + --update) RECREATE=0; shift ;; + -h|--help) sed -n '2,37p' "$0" | sed 's/^#\{0,1\} \{0,1\}//'; exit 0 ;; *) err "unknown option: $1 (use --help)" ;; esac done @@ -59,6 +86,16 @@ done VENV_DIR="${VENV_DIR:-$PREFIX/share/sonpipe/venv}" BIN_DIR="${BIN_DIR:-$PREFIX/bin}" +OS="$(uname -s)" +ARCH="$(uname -m)" +# On Apple Silicon, CED's sonpy is x86_64-only AND is linked against the +# python.org framework build, so we build and run the environment as x86_64 +# (Rosetta) using that framework Python. +MAC_ARM=0 +if [ "$OS" = "Darwin" ] && [ "$ARCH" = "arm64" ]; then MAC_ARM=1; fi +PYRUN="" +[ "$MAC_ARM" -eq 1 ] && PYRUN="arch -x86_64" + # Resolve the directory this script lives in (following symlinks). SOURCE_PATH="${BASH_SOURCE[0]}" while [ -h "$SOURCE_PATH" ]; do @@ -68,20 +105,52 @@ while [ -h "$SOURCE_PATH" ]; do done SCRIPT_DIR="$(cd -P "$(dirname "$SOURCE_PATH")" >/dev/null 2>&1 && pwd)" -# Pick a Python interpreter (prefer 3.14 for sonpy wheel availability). -if [ -z "$PYTHON" ]; then - for c in python3.14 python3 python; do - if command -v "$c" >/dev/null 2>&1; then PYTHON="$c"; break; fi - done +# --recreate wipes any existing environment so we build fresh. +if [ "$RECREATE" -eq 1 ] && [ -d "$VENV_DIR" ]; then + log "Removing existing environment (--recreate): $VENV_DIR" + rm -rf "$VENV_DIR" fi -[ -n "$PYTHON" ] || err "no Python interpreter found; install Python 3.14 and retry." -command -v "$PYTHON" >/dev/null 2>&1 || err "python not found: $PYTHON" - -PYVER="$("$PYTHON" -c 'import sys; print("%d.%d" % sys.version_info[:2])')" -log "Using Python $PYVER ($("$PYTHON" -c 'import sys; print(sys.executable)'))" -if [ "$PYVER" != "3.14" ]; then - warn "CED sonpy ships Linux/macOS wheels only for Python 3.14; you are on $PYVER." - warn "If sonpy fails to import below, re-run with: --python \"\$(command -v python3.14)\"" +# Re-running over an existing venv is a fast in-place update. +REUSE=0 +[ -f "$VENV_DIR/pyvenv.cfg" ] && REUSE=1 + +# A base interpreter is only needed to *create* a venv. Updating an existing one +# reuses its interpreter, so skip Python discovery/validation entirely. +if [ "$REUSE" -eq 0 ]; then + # Pick a Python interpreter (prefer 3.14 for sonpy wheel availability). + if [ -z "$PYTHON" ]; then + candidates="python3.14 python3 python" + # On macOS, prefer the python.org framework build -- CED's sonpy is linked + # against it and will not load under other distributions (uv, Homebrew, ...). + if [ "$OS" = "Darwin" ]; then + candidates="/Library/Frameworks/Python.framework/Versions/3.14/bin/python3.14 $candidates" + fi + for c in $candidates; do + if command -v "$c" >/dev/null 2>&1; then PYTHON="$c"; break; fi + done + fi + [ -n "$PYTHON" ] || err "no Python interpreter found; install Python 3.14 and retry." + command -v "$PYTHON" >/dev/null 2>&1 || err "python not found: $PYTHON" + + # On Apple Silicon the interpreter must be able to run as x86_64 (i.e. a + # universal2 build such as the python.org installer). Homebrew/arm64-only + # builds cannot, and uv's standalone build lacks the framework sonpy needs. + if [ "$MAC_ARM" -eq 1 ] && ! $PYRUN "$PYTHON" -c 'pass' >/dev/null 2>&1; then + warn "The chosen Python cannot run as x86_64 (it looks arm64-only)." + warn "On Apple Silicon, install the python.org universal2 build of Python 3.14:" + warn " https://www.python.org/downloads/macos/" + warn "then re-run (the installer will find it automatically), or pass it explicitly:" + warn " ./install.sh --python /Library/Frameworks/Python.framework/Versions/3.14/bin/python3.14" + exit 1 + fi + + PYVER="$($PYRUN "$PYTHON" -c 'import sys; print("%d.%d" % sys.version_info[:2])')" + log "Using Python $PYVER ($($PYRUN "$PYTHON" -c 'import sys; print(sys.executable)'))" + [ "$MAC_ARM" -eq 1 ] && log "Apple Silicon: building an x86_64 environment via Rosetta (arch -x86_64)." + if [ "$PYVER" != "3.14" ]; then + warn "CED sonpy ships Linux/macOS wheels only for Python 3.14; you are on $PYVER." + warn "If sonpy fails to import below, re-run with: --python \"\$(command -v python3.14)\"" + fi fi # Decide what to install. @@ -90,40 +159,69 @@ if [ -z "$SOURCE" ]; then fi log "Installing from: $SOURCE" -log "Creating virtual environment: $VENV_DIR" -mkdir -p "$(dirname "$VENV_DIR")" -"$PYTHON" -m venv "$VENV_DIR" +if [ "$REUSE" -eq 1 ]; then + log "Updating existing environment: $VENV_DIR (use --recreate for a clean rebuild)" +else + log "Creating virtual environment: $VENV_DIR" + mkdir -p "$(dirname "$VENV_DIR")" + $PYRUN "$PYTHON" -m venv "$VENV_DIR" +fi PY="$VENV_DIR/bin/python" +# Version of the (possibly pre-existing) environment; used in messages below and +# always defined, including on the reuse/update path. +PYVER="$($PYRUN "$PY" -c 'import sys; print("%d.%d" % sys.version_info[:2])')" log "Upgrading pip" -"$PY" -m pip install --upgrade pip >/dev/null +$PYRUN "$PY" -m pip install --upgrade pip >/dev/null -log "Installing sonpipe (also fetches CED's sonpy from PyPI)" -"$PY" -m pip install "$SOURCE" +# --upgrade updates sonpipe in place; dependencies (sonpy, numpy) are only +# touched if a version constraint requires it, so re-running is a fast update. +log "Installing/updating sonpipe (fetches CED's sonpy from PyPI if needed)" +$PYRUN "$PY" -m pip install --upgrade "$SOURCE" # Verify. log "Verifying sonpipe CLI ..." -"$PY" -m sonpipe --version || err "sonpipe did not install correctly." +$PYRUN "$PY" -m sonpipe --version || err "sonpipe did not install correctly." log "Verifying CED sonpy import ..." SONPY_OK=1 -if ! "$PY" -c "import sonpy; print('sonpy', sonpy.__version__)"; then +if ! $PYRUN "$PY" -c "from sonpipe.sonfile import load_sonpy; load_sonpy()"; then SONPY_OK=0 - warn "sonpy could not be imported -- most likely no sonpy wheel exists for" - warn "Python $PYVER on this platform. Install Python 3.14 and re-run:" - warn " ./install.sh --python \"\$(command -v python3.14)\"" + if [ "$OS" = "Darwin" ]; then + warn "sonpy could not be imported. On macOS, CED's sonpy is x86_64-only and is" + warn "linked against the python.org framework Python. Install Python 3.14 from" + warn " https://www.python.org/downloads/macos/" + warn "and re-run (it will be found automatically), or pass it explicitly:" + warn " ./install.sh --python /Library/Frameworks/Python.framework/Versions/3.14/bin/python3.14" + else + warn "sonpy could not be imported -- most likely no sonpy wheel exists for" + warn "Python $PYVER on this platform. Install Python 3.14 and re-run:" + warn " ./install.sh --python \"\$(command -v python3.14)\"" + fi fi -# Symlink the command onto the PATH. +# Put the command on the PATH. CMD="$VENV_DIR/bin/sonpipe" if [ "$SYMLINK" -eq 1 ]; then mkdir -p "$BIN_DIR" - ln -sf "$CMD" "$BIN_DIR/sonpipe" + if [ "$MAC_ARM" -eq 1 ]; then + # A wrapper (not a symlink) so the command always runs as x86_64 under + # Rosetta -- including when launched from a native-arm64 host like MATLAB, + # where a symlink's shebang would run arm64 and fail to load sonpy. + rm -f "$BIN_DIR/sonpipe" + cat > "$BIN_DIR/sonpipe" <> ~/.profile" ;; + *) warn "$BIN_DIR is not on your PATH. Add it with:" + path_hint "$BIN_DIR" ;; esac fi diff --git a/src/sonpipe/sonfile.py b/src/sonpipe/sonfile.py index 967c14e..d08b4b2 100644 --- a/src/sonpipe/sonfile.py +++ b/src/sonpipe/sonfile.py @@ -96,7 +96,9 @@ def __init__(self, path, sonlib=None): if sonlib is None: sonlib = load_sonpy() self._son = sonlib - self.path = os.fspath(path) + # Expand a leading ~ (callers such as MATLAB pass it through literally, + # and neither the shell nor Python expands it inside a quoted argument). + self.path = os.path.expanduser(os.fspath(path)) if not os.path.exists(self.path): raise SonpipeError("File not found: {}".format(self.path)) diff --git a/tests/test_sonfile.py b/tests/test_sonfile.py index 02a1bd3..dde1727 100644 --- a/tests/test_sonfile.py +++ b/tests/test_sonfile.py @@ -1,5 +1,6 @@ """Tests for the SmrxFile wrapper and channel mappings.""" +import os import types import numpy as np @@ -68,6 +69,16 @@ def test_missing_file_raises(): open_file("/no/such/file.smrx") +def test_leading_tilde_is_expanded(tmp_path, monkeypatch): + # HOME (Unix) / USERPROFILE (Windows) drive os.path.expanduser. + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + (tmp_path / "ex.smrx").write_bytes(b"") + smrx = open_file("~/ex.smrx") + # normpath so the comparison is separator-agnostic (Windows mixes / and \). + assert os.path.normpath(smrx.path) == os.path.normpath(str(tmp_path / "ex.smrx")) + + def test_channel_numbers_skip_off(smrx_path): smrx = open_file(smrx_path) # slot index 1 (Spike2 #2) is Off and must be omitted. diff --git a/update.sh b/update.sh new file mode 100755 index 0000000..f9d4cab --- /dev/null +++ b/update.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# +# sonpipe updater for Linux and macOS. +# +# Convenience wrapper around install.sh: updates the sonpipe package in your +# existing environment in place (no venv rebuild, no re-download of sonpy). +# All arguments are forwarded to install.sh, so e.g. `./update.sh --recreate` +# forces a clean rebuild instead. +# +# Typical use after a `git pull`: +# ./update.sh + +set -euo pipefail + +DIR="$(cd -P "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)" +exec "$DIR/install.sh" --update "$@"