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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions .github/workflows/snap.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
name: snap

# Snap is its own pipeline, separate from the wheel-derived artifacts: it doesn't
# consume the shared wheel, it ships its own runtime (base: core24), and it
# publishes to the Snap Store rather than PyPI / the GitHub Release. It's also a
# heavy build, so it runs only on a tag or on demand — not on every push/PR.
on:
workflow_dispatch:
push:
tags:
- "v*"

jobs:
# Build and smoke-test the snap, then hand it off as an artifact. This runs
# freely; only the store upload below is gated.
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

# action-build installs LXD + snapcraft and builds inside a core24
# container, so the runner OS is irrelevant. The version comes from the
# AppStream <release> via adopt-info in snapcraft.yaml.
- name: Build the snap
uses: canonical/action-build@v1.3.0
id: snapcraft

# Catch "builds but won't run" before it can reach the store.
- name: Smoke-test the snap
run: |
sudo snap install --dangerous ${{ steps.snapcraft.outputs.snap }}
npyquick --version

- name: Upload the snap
uses: actions/upload-artifact@v4
with:
name: snap
path: ${{ steps.snapcraft.outputs.snap }}

# Upload to the Snap Store's edge channel. Gated on the `snap-store`
# environment (like publish-pypi is on `pypi`): the job waits for a manual
# approval before anything is published. Promotion edge -> stable stays a
# separate manual step. Needs the SNAPCRAFT_STORE_CREDENTIALS secret (from
# `snapcraft export-login`); scope it to this environment.
publish:
needs: build
runs-on: ubuntu-latest
environment: snap-store
steps:
- name: Download the snap
uses: actions/download-artifact@v4
with:
name: snap
path: snap-artifact

- name: Locate the snap
id: loc
run: echo "path=$(ls snap-artifact/*.snap)" >> "$GITHUB_OUTPUT"

- name: Publish to the edge channel
uses: canonical/action-publish@v1.2.0
env:
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }}
with:
snap: ${{ steps.loc.outputs.path }}
release: edge
16 changes: 15 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
<a href="https://pypi.org/project/npyquick/"><img alt="Python versions" src="https://img.shields.io/pypi/pyversions/npyquick"></a>
<a href="https://github.com/LiukDiihMieu/npyquick/releases"><img alt="Windows EXE" src="https://img.shields.io/badge/Windows-EXE-blue?logo=windows"></a>
<a href="https://github.com/LiukDiihMieu/npyquick/releases"><img alt="Linux AppImage" src="https://img.shields.io/badge/Linux-AppImage-blue?logo=linux"></a>
<a href="https://snapcraft.io/npyquick"><img alt="Snap Store" src="https://img.shields.io/snapcraft/v/npyquick/latest/stable"></a>
<a href="LICENSE"><img alt="License" src="https://img.shields.io/github/license/LiukDiihMieu/npyquick"></a>
</p>

Expand All @@ -31,7 +32,7 @@ npyquick is designed as a small, practical viewer for this job:

**Linux (AppImage):**

On x86-64 Linux, AppImage is available for one-click installation. Download `npyquick-x86_64.AppImage` from the [latest release](https://github.com/LiukDiihMieu/npyquick/releases/latest), then:
On x86-64 Linux, AppImage is available for one-click installation. Download `npyquick-<version>-x86_64.AppImage` from the [latest release](https://github.com/LiukDiihMieu/npyquick/releases/latest), then:

```bash
chmod +x npyquick-x86_64.AppImage
Expand All @@ -40,6 +41,19 @@ chmod +x npyquick-x86_64.AppImage

To open `.npy` / `.npz` by double-clicking in your file manager, register the default handler once with `./npyquick-x86_64.AppImage --install-desktop` (details in [Linux desktop integration](#linux-desktop-integration)). If you integrate the AppImage with a tool like [AppImageLauncher](https://github.com/TheAssassin/AppImageLauncher), this association is set up for you automatically.

**Linux (Snap):**

On Ubuntu and other distributions with snap support, install from the Snap Store:

```bash
sudo snap install npyquick
```

The Snap has two limitations the AppImage and pip builds don't:

- **File access:** it's sandboxed to your home folder, plus removable media after you run `sudo snap connect npyquick:removable-media`. Files on other drives won't open — including home folders that are symlinks to another disk — so use the AppImage or pip install for those.
- **No double-click:** the Snap can't register `.npy` / `.npz` file associations, so double-clicking won't launch it. Open npyquick first, then drag a file onto the window, or use File › Open.

**Windows:**

Download the `npyquick-<version>-setup.exe` installer from the [latest release](https://github.com/LiukDiihMieu/npyquick/releases/latest) and run it. It installs per-user (no administrator rights needed) and adds a Start Menu shortcut. To open `.npy` / `.npz` by double-clicking, tick *Associate .npy and .npz files with npyquick* during setup.
Expand Down
14 changes: 14 additions & 0 deletions docs/RELEASE_CHECKLIST.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,20 @@ verification of what CI produced (and the recipe if you ever build locally).
- [ ] `--install-desktop` writes a correct desktop entry.
- [ ] AppImageLauncher integration still works.

## Linux Snap

Built and published manually (not by the tag CI), on an Ubuntu 22.04 host with LXD.

- [ ] `snapcraft pack --use-lxd` builds the `.snap` (version is adopted from the AppStream `<release>`).
- [ ] Installs with `sudo snap install --dangerous`.
- [ ] `.npy` and `.npz` sample files open across the image / table / cross-section / histogram views.
- [ ] Launches from the application menu with the correct icon.
- [ ] Works on both X11 and Wayland sessions (the Qt xcb path needs the staged xcb/xkb libs).
- [ ] A file outside the sandbox (e.g. on another drive) shows the sandbox hint, not a raw error.
- [ ] Uploaded to the edge channel: `snapcraft upload --release=edge ...`.
- [ ] After edge verification, promoted to stable: `snapcraft release npyquick <rev> stable`.
- [ ] Store text/icon updated with `snapcraft upload-metadata <snap> --force`; links and screenshots set in the web dashboard.

## Windows installer

- [ ] PyInstaller onedir build succeeds on Windows.
Expand Down
26 changes: 26 additions & 0 deletions snap/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# npyquick (Snap)

Snap packaging for npyquick. Build locally with:

```sh
snapcraft pack --use-lxd
```

## Opening files

The Snap is strictly confined, so it reads files in your home directory (and
removable media once that interface is connected). To open an array:

- **Drag and drop** a `.npy` or `.npz` file onto the window — the quickest way.
- Or use **File ▸ Open**.

### Why double-click doesn't open npyquick

Double-clicking a `.npy` / `.npz` in the file manager will not launch npyquick.
A Snap cannot register new file types with the host system, so the desktop
doesn't recognise these extensions as belonging to npyquick. This is a
limitation of how Snaps integrate with the system, not a setting we can flip
in the package (it is unrelated to the sandbox — the same gap exists without
strict confinement).

Open npyquick from the application menu, then drag a file in or use File ▸ Open.
12 changes: 12 additions & 0 deletions snap/gui/npyquick.desktop
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
[Desktop Entry]
Type=Application
Name=npyquick
GenericName=NumPy Array Viewer
Comment=Quick viewer for NumPy .npy and .npz files
Exec=npyquick %f
Icon=${SNAP}/meta/gui/npyquick.png
Terminal=false
Categories=Science;Utility;
MimeType=application/x-npy;application/x-npz;
Keywords=numpy;npy;npz;array;viewer;
StartupNotify=true
Binary file added snap/gui/npyquick.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
68 changes: 68 additions & 0 deletions snap/snapcraft.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
name: npyquick
base: core24
# title, version, summary and description are adopted from the AppStream
# metainfo file (see the `metadata` part), keeping them in one place shared
# with the AppImage build instead of hand-maintaining them here.
adopt-info: metadata
# Store/launcher icon. AppStream has no <icon> element, so set it explicitly.
# Store icon limits: <=512x512, 1:1, <=256 KB.
icon: snap/gui/npyquick.png
# Store listing links (AppStream URLs are not adopted, so keep these).
contact: https://github.com/LiukDiihMieu/npyquick/issues
issues: https://github.com/LiukDiihMieu/npyquick/issues
source-code: https://github.com/LiukDiihMieu/npyquick

license: GPL-3.0-or-later
grade: stable
confinement: strict

apps:
npyquick:
command: bin/npyquick
# The gnome extension provides common desktop integration pieces
# such as fonts, themes, icons, Wayland/X11, and related runtime setup.
# PySide6/Qt itself still comes from the Python dependencies.
extensions: [gnome]
common-id: io.github.liukdiihmieu.npyquick
# The .desktop entry and icon come from snap/gui/ (copied to meta/gui/ at
# build; snapd auto-detects them). The desktop file's Icon= points at
# ${SNAP}/meta/gui/npyquick.png — an absolute in-snap path, which is what
# the launcher needs to resolve a bundled icon.
plugs:
# Allow opening user data from the home directory.
# Files under /mnt, /media, or /run/media require the removable-media
# interface to be connected manually.
- home
- removable-media

parts:
npyquick:
plugin: python
source: .
stage-packages:
# System libraries the Qt6 xcb (X11) platform plugin links against. The
# gnome platform supplies some xcb libs but not all of these; without them
# the app runs on Wayland but crashes on X11 with "Could not load the Qt
# platform plugin xcb". The missing set was confirmed via `ldd libqxcb.so`
# on an X11 host. (Qt's error singles out xcb-cursor, but the real gaps are
# the xkb/icccm/keysyms/shape ones below.)
- libxcb-cursor0
- libxcb-icccm4
- libxcb-keysyms1
- libxcb-shape0
- libxcb-xkb1
- libxkbcommon-x11-0

# Adopt store metadata (title/version/summary/description) from the AppStream
# metainfo file, reusing the one written for the AppImage. Also ships it at
# the standard usr/share/metainfo/ path so the installed snap carries proper
# AppStream metadata.
metadata:
plugin: dump
source: packaging/appimage
organize:
io.github.liukdiihmieu.npyquick.appdata.xml: usr/share/metainfo/io.github.liukdiihmieu.npyquick.appdata.xml
stage:
- usr/share/metainfo
parse-info:
- usr/share/metainfo/io.github.liukdiihmieu.npyquick.appdata.xml
28 changes: 27 additions & 1 deletion src/npyquick/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -406,11 +406,37 @@ def open_file(self) -> None:
if path:
self.load_file(path)

@staticmethod
def _snap_sandbox_hint(path: str) -> str | None:
"""When running as a confined Snap, explain why a file the sandbox
can't reach failed to open. Returns None unless we are a Snap *and*
the path is genuinely unreachable from inside confinement — so a
visible-but-failing file (e.g. corrupt) keeps its real error.
"""
if not os.environ.get("SNAP"):
return None
# Reachable from inside the Snap -> the failure has another cause.
if os.path.exists(path):
return None
real = os.path.realpath(path)
home = os.path.realpath(os.environ.get("SNAP_REAL_HOME") or os.path.expanduser("~"))
if real == home or real.startswith(home + os.sep):
return None # a missing file under home is just a normal "not found"
if real.startswith(("/media/", "/run/media/", "/mnt/")):
return (
"Can't open this file — if it's on a USB/external drive, run: "
"sudo snap connect npyquick:removable-media"
)
return (
"Can't open this file — Snap can only access your home folder "
"and connected removable-media locations."
)

def load_file(self, path: str) -> bool:
try:
self._model.load(path)
except Exception as exc:
self._sb.showMessage(f"Error loading {path}: {exc}")
self._sb.showMessage(self._snap_sandbox_hint(path) or f"Error loading {path}: {exc}")
return False

self._current_path = path
Expand Down
65 changes: 65 additions & 0 deletions tests/test_app_workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
from __future__ import annotations

import numpy as np
import pytest

from npyquick.app import MainWindow


# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -42,6 +45,68 @@ def test_corrupt_npy_load_preserves_previously_loaded_array(
np.testing.assert_array_equal(main_window._model.array, prior)


# ---------------------------------------------------------------------------
# load_file() Snap-sandbox hint: when confined as a Snap, a path the sandbox
# can't reach gets a clear explanation instead of a raw "No such file" error.
# ---------------------------------------------------------------------------

def test_sandbox_hint_absent_outside_snap(monkeypatch):
monkeypatch.delenv("SNAP", raising=False)
assert MainWindow._snap_sandbox_hint("/nonexistent_drive/x.npy") is None


def test_sandbox_hint_absent_for_paths_under_home(monkeypatch):
monkeypatch.setenv("SNAP", "/snap/npyquick/x1")
monkeypatch.setenv("SNAP_REAL_HOME", "/home/alice")
assert MainWindow._snap_sandbox_hint("/home/alice/lab/x.npy") is None


def test_sandbox_hint_for_other_drive(monkeypatch):
monkeypatch.setenv("SNAP", "/snap/npyquick/x1")
monkeypatch.setenv("SNAP_REAL_HOME", "/home/alice")
msg = MainWindow._snap_sandbox_hint("/nonexistent_drive/experiments/x.npy")
assert msg is not None and "home folder" in msg and "snap connect" not in msg


@pytest.mark.parametrize("path", [
"/media/nonexistent/usb/x.npy",
"/run/media/alice/USB/x.npy",
"/mnt/nonexistent/x.npy",
])
def test_sandbox_hint_for_removable_media(path, monkeypatch):
monkeypatch.setenv("SNAP", "/snap/npyquick/x1")
monkeypatch.setenv("SNAP_REAL_HOME", "/home/alice")
msg = MainWindow._snap_sandbox_hint(path)
assert msg is not None and "sudo snap connect npyquick:removable-media" in msg


def test_sandbox_hint_follows_symlink_to_other_drive(tmp_path, monkeypatch):
"""A symlink under home pointing to another drive resolves out of reach."""
monkeypatch.setenv("SNAP", "/snap/npyquick/x1")
monkeypatch.setenv("SNAP_REAL_HOME", str(tmp_path)) # treat tmp_path as home
link = tmp_path / "link.npy"
link.symlink_to("/nonexistent_drive/experiments/x.npy")
msg = MainWindow._snap_sandbox_hint(str(link))
assert msg is not None and "home folder" in msg


def test_sandbox_hint_absent_when_path_is_visible(tmp_path, monkeypatch):
"""A reachable file that fails for another reason keeps its real error."""
monkeypatch.setenv("SNAP", "/snap/npyquick/x1")
monkeypatch.setenv("SNAP_REAL_HOME", str(tmp_path / "home")) # home elsewhere
f = tmp_path / "x.npy"
f.write_bytes(b"x") # exists / visible, but outside the (fake) home
assert MainWindow._snap_sandbox_hint(str(f)) is None


def test_load_file_uses_sandbox_hint_when_confined(main_window, monkeypatch):
"""A failed load of an out-of-sandbox path reports the hint, not the raw error."""
monkeypatch.setenv("SNAP", "/snap/npyquick/x1")
monkeypatch.setenv("SNAP_REAL_HOME", "/home/alice")
main_window.load_file("/nonexistent_drive/experiments/missing.npy")
assert "home folder" in main_window._sb.currentMessage()


# ---------------------------------------------------------------------------
# Export Plot menu shape per view type, plus correct rebuild on transitions.
# ---------------------------------------------------------------------------
Expand Down
Loading