From 0b26c85622a36eb141fe65c2746123024a8daf24 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Mon, 16 Feb 2026 19:41:16 +0100 Subject: [PATCH 01/21] Pin deeplabcut-live to 1.1.0 in pyproject Replace flexible >=2 specifiers with an exact deeplabcut-live==1.1.0 for the main dependency and its [pytorch] and [tf] extras in pyproject.toml. This pins the package (and its included extras like timm and scipy) to a known compatible release to avoid missing-dependency or compatibility issues. --- pyproject.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 9dac41d..c7ab122 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,7 @@ classifiers = [ dynamic = [ "version" ] dependencies = [ "cv2-enumerate-cameras", - "deeplabcut-live>=2", # might be missing timm and scipy + "deeplabcut-live==1.1", "matplotlib", "numpy", "opencv-python", @@ -49,7 +49,7 @@ dev = [ ] gentl = [ "harvesters" ] pytorch = [ - "deeplabcut-live[pytorch]>=2", # this includes timm and scipy + "deeplabcut-live[pytorch]==1.1", # includes timm and scipy ] test = [ "hypothesis>=6", @@ -59,7 +59,7 @@ test = [ "pytest-qt>=4.2", ] tf = [ - "deeplabcut-live[tf]>=2", + "deeplabcut-live[tf]==1.1", ] [project.scripts] dlclivegui = "dlclivegui:main" From 05497e82143c3379d037386e4fa0cc9c65d2a62c Mon Sep 17 00:00:00 2001 From: C-Achard Date: Mon, 16 Feb 2026 20:22:57 +0100 Subject: [PATCH 02/21] Add PyPI release workflow and rename CI file Add a new GitHub Actions workflow (python-package.yml) to build and publish packages to PyPI. The workflow triggers on tag pushes (v*.*.*) and on PR events, sets up Python, caches pip, installs build tooling (build, twine, packaging), builds the package, and uploads artifacts to PyPI using TWINE_API_KEY. Also rename .github/workflows/ci.yml to .github/workflows/testing-ci.yml to clarify CI purpose. --- .github/workflows/python-package.yml | 59 ++++++++++++++++++++ .github/workflows/{ci.yml => testing-ci.yml} | 0 2 files changed, 59 insertions(+) create mode 100644 .github/workflows/python-package.yml rename .github/workflows/{ci.yml => testing-ci.yml} (100%) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml new file mode 100644 index 0000000..d588e95 --- /dev/null +++ b/.github/workflows/python-package.yml @@ -0,0 +1,59 @@ +name: Update pypi release + +on: + push: + tags: + - 'v*.*.*' + pull_request: + branches: + - main + - public + types: + - labeled + - opened + - edited + - synchronize + - reopened + +jobs: + release: + runs-on: ubuntu-latest + + steps: + - name: Setup Python + id: setup-python + uses: actions/setup-python@v5 + with: + python-version: '3.x' + + - name: Cache dependencies + id: pip-cache + uses: actions/cache@v4 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('pyproject.toml', 'requirements.txt', 'setup.cfg', 'setup.py') }} + restore-keys: | + ${{ runner.os }}-pip-${{ steps.setup-python.outputs.python-version }}- + ${{ runner.os }}-pip- + + - name: Install dependencies + run: | + pip install --upgrade pip + pip install wheel + pip install "packaging>=24.2" + pip install build + pip install twine + + - name: Checkout code + uses: actions/checkout@v4 + + - name: Build and publish to PyPI + if: ${{ github.event_name == 'push' }} + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.TWINE_API_KEY }} + run: | + python -m build + ls dist/ + tar tvf dist/*.tar.gz + python3 -m twine upload --verbose dist/* diff --git a/.github/workflows/ci.yml b/.github/workflows/testing-ci.yml similarity index 100% rename from .github/workflows/ci.yml rename to .github/workflows/testing-ci.yml From 1cc7801e3bca69c2b613e49aaec6af4667b864e6 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Mon, 16 Feb 2026 20:49:19 +0100 Subject: [PATCH 03/21] CI: build, validate & release Python package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename workflow and split into a build/validate job and a gated publish job. Add a Python matrix (3.10–3.12), cache keyed by matrix python-version, and update checkout/setup/cache action usages. Build sdist/wheel, run twine check, inspect dist contents, and perform a smoke install from the built wheel (with a best-effort CLI check). Publish step now runs only for tag pushes like vX.Y.Z and uploads with twine using the TWINE_API_KEY secret. --- .github/workflows/python-package.yml | 91 ++++++++++++++++++++-------- 1 file changed, 67 insertions(+), 24 deletions(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index d588e95..8a15aaf 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -1,4 +1,4 @@ -name: Update pypi release +name: Build, validate & Release on: push: @@ -16,44 +16,87 @@ on: - reopened jobs: - release: + build_check: + name: Build & validate package runs-on: ubuntu-latest - + strategy: + fail-fast: false + matrix: + python-version: [ "3.10", "3.11", "3.12" ] # adjust to what you support steps: - - name: Setup Python - id: setup-python + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: - python-version: '3.x' + python-version: ${{ matrix.python-version }} - - name: Cache dependencies - id: pip-cache + - name: Cache pip uses: actions/cache@v4 with: path: ~/.cache/pip - key: ${{ runner.os }}-pip-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('pyproject.toml', 'requirements.txt', 'setup.cfg', 'setup.py') }} + key: ${{ runner.os }}-pip-${{ matrix.python-version }}-${{ hashFiles('pyproject.toml', 'setup.cfg', 'setup.py', 'requirements.txt') }} restore-keys: | - ${{ runner.os }}-pip-${{ steps.setup-python.outputs.python-version }}- + ${{ runner.os }}-pip-${{ matrix.python-version }}- ${{ runner.os }}-pip- - - name: Install dependencies + - name: Install build tools run: | - pip install --upgrade pip - pip install wheel - pip install "packaging>=24.2" - pip install build - pip install twine + python -m pip install --upgrade pip + python -m pip install build twine wheel "packaging>=24.2" + + - name: Build distributions (sdist + wheel) + run: python -m build + + - name: Inspect dist + run: | + ls -lah dist/ + echo "sdist contents (first ~200 entries):" + tar -tf dist/*.tar.gz | sed -n '1,200p' + + - name: Twine metadata & README check + run: python -m twine check dist/* + + - name: Install from wheel & smoke test + run: | + # Install from the built wheel (not from the source tree) + python -m pip install --no-deps dist/*.whl + + python - <<'PY' + import importlib + pkg_name = "dlclivegui" # change if your top-level import differs + m = importlib.import_module(pkg_name) + print("Imported:", m.__name__, "version:", getattr(m, "__version__", "n/a")) + PY - - name: Checkout code + # Console entry point best-effort check (adjust name if different) + (command -v dlclivegui && dlclivegui --help) || echo "CLI not available or returned non-zero; continuing." + + publish: + name: Publish to PyPI + runs-on: ubuntu-latest + needs: build_check + if: ${{ startsWith(github.ref, 'refs/tags/v') }} # only on tag pushes like v1.2.3 + steps: + - name: Checkout uses: actions/checkout@v4 - - name: Build and publish to PyPI - if: ${{ github.event_name == 'push' }} + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.x" + + - name: Install build tools + run: | + python -m pip install --upgrade pip + python -m pip install build twine + + - name: Build distributions (sdist + wheel) + run: python -m build + + - name: Publish to PyPI env: TWINE_USERNAME: __token__ TWINE_PASSWORD: ${{ secrets.TWINE_API_KEY }} - run: | - python -m build - ls dist/ - tar tvf dist/*.tar.gz - python3 -m twine upload --verbose dist/* + run: python -m twine upload --verbose dist/* From ae9550c5d1d023b6354bc9b3bb28439c9da44c4f Mon Sep 17 00:00:00 2001 From: C-Achard Date: Mon, 16 Feb 2026 20:50:23 +0100 Subject: [PATCH 04/21] Set version in pyproject.toml to 2.0.0rc0 Replace dynamic = ["version"] with an explicit version = "2.0.0rc0" placeholder in pyproject.toml. This makes the package version static (release candidate) for packaging/CI needs; update the placeholder as appropriate for final releases. --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index c7ab122..ba9369a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,6 +4,7 @@ requires = [ "setuptools>=68" ] [project] name = "deeplabcut-live-gui" +version = "2.0.0rc0" # PLACEHOLDER description = "PySide6-based GUI to run real time DeepLabCut experiments" readme = "README.md" keywords = [ "deep learning", "deeplabcut", "gui", "pose estimation", "real-time" ] @@ -21,7 +22,6 @@ classifiers = [ "Programming Language :: Python :: 3.12", "Topic :: Scientific/Engineering :: Artificial Intelligence", ] -dynamic = [ "version" ] dependencies = [ "cv2-enumerate-cameras", "deeplabcut-live==1.1", From 21a7e280f88ce385838806a6fc0f980124006a5c Mon Sep 17 00:00:00 2001 From: C-Achard Date: Mon, 16 Feb 2026 21:02:12 +0100 Subject: [PATCH 05/21] Update project metadata in pyproject.toml Clarify package description to mention pose estimation experiments with DeepLabCut; fix authors by splitting the previous entry into two distinct authors (M-Lab of Adaptive Intelligence and Mathis Group for Computational Neuroscience and AI) with their respective emails; add a FIXME comment to the Documentation URL to mark docs as a placeholder. --- pyproject.toml | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ba9369a..716079a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ requires = [ "setuptools>=68" ] [project] name = "deeplabcut-live-gui" version = "2.0.0rc0" # PLACEHOLDER -description = "PySide6-based GUI to run real time DeepLabCut experiments" +description = "PySide6-based GUI to run real time pose estimation experiments with DeepLabCut" readme = "README.md" keywords = [ "deep learning", "deeplabcut", "gui", "pose estimation", "real-time" ] license-files = [ "LICENSE" ] @@ -34,8 +34,11 @@ dependencies = [ "vidgear[core]", ] [[project.authors]] -name = "A. & M. Mathis Labs" -email = "adim@deeplabcut.org" +name = "M-Lab of Adaptive Intelligence" +email = "mackenzie@deeplabcut.org" +[[project.authors]] +name = "Mathis Group for Computational Neuroscience and AI" +email = "alexander@deeplabcut.org" [project.optional-dependencies] all = [ "harvesters", "pypylon" ] basler = [ "pypylon" ] @@ -65,7 +68,7 @@ tf = [ dlclivegui = "dlclivegui:main" [project.urls] "Bug Tracker" = "https://github.com/DeepLabCut/DeepLabCut-live-GUI/issues" -Documentation = "https://github.com/DeepLabCut/DeepLabCut-live-GUI" +Documentation = "https://github.com/DeepLabCut/DeepLabCut-live-GUI" # FIXME @C-Achard replace once docs are up Homepage = "https://github.com/DeepLabCut/DeepLabCut-live-GUI" Repository = "https://github.com/DeepLabCut/DeepLabCut-live-GUI" From 3ca5d4c80fc40f3626ff9057cbcdf3ad6aa13c9b Mon Sep 17 00:00:00 2001 From: C-Achard Date: Mon, 16 Feb 2026 21:09:30 +0100 Subject: [PATCH 06/21] CI: note lockfile and install wheel with deps Add a comment in the Python CI workflow recommending inclusion of the lock file for caching and validation. Update the smoke-test step to install the built wheel along with its dependencies by removing `--no-deps`. Also include minor whitespace cleanup. --- .github/workflows/python-package.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 8a15aaf..077d303 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -32,6 +32,7 @@ jobs: with: python-version: ${{ matrix.python-version }} + # If we use UV later, the lock file should be included here for caching and validation - name: Cache pip uses: actions/cache@v4 with: @@ -61,7 +62,7 @@ jobs: - name: Install from wheel & smoke test run: | # Install from the built wheel (not from the source tree) - python -m pip install --no-deps dist/*.whl + python -m pip install dist/*.whl python - <<'PY' import importlib From 002e06b9cd203b4a20ca9dcc5dc8a1b7e7e5cec3 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Mon, 16 Feb 2026 22:30:11 +0100 Subject: [PATCH 07/21] Add ASCII art API, CLI flag, and fixes Introduce dlclivegui.assets.ascii_art: a cross-platform, CI-safe ASCII/ANSI art generator using OpenCV and numpy (alpha compositing, content cropping, resizing, color modes, and terminal detection). Integrate it into main: add argparse with a --no-art flag, print a startup banner (unless disabled), and use the art helper for help text. Fix QApplication SIGINT keepalive timer handling (avoid duplicate timers and stop existing one before replacing). Change default SHOW_SPLASH to False in theme.py and update tests to restore SHOW_SPLASH=True via an autouse fixture so existing splash-dependent tests continue to pass. --- dlclivegui/assets/ascii_art.py | 371 +++++++++++++++++++++++++++++++ dlclivegui/gui/theme.py | 2 +- dlclivegui/main.py | 25 ++- tests/gui/test_app_entrypoint.py | 6 + 4 files changed, 397 insertions(+), 7 deletions(-) create mode 100644 dlclivegui/assets/ascii_art.py diff --git a/dlclivegui/assets/ascii_art.py b/dlclivegui/assets/ascii_art.py new file mode 100644 index 0000000..42d97d0 --- /dev/null +++ b/dlclivegui/assets/ascii_art.py @@ -0,0 +1,371 @@ +""" +Utilities to generate ASCII (optionally ANSI-colored) art for the user's terminal. + +Cross-platform and CI-safe: +- Detects terminal width using shutil.get_terminal_size (portable across OSes). +- Respects NO_COLOR and a color mode (auto|always|never). +- Enables ANSI color on Windows PowerShell/cmd via os.system("") when needed. +- Supports transparent PNGs (alpha) by compositing over a chosen background color. +- Optional crop-to-content using alpha or a background heuristic when no alpha. + +Dependencies: opencv-python, numpy +""" + +# dlclivegui/assets/ascii.py +from __future__ import annotations + +import os +import shutil +import sys +from collections.abc import Iterable +from importlib import resources +from typing import Literal + +import numpy as np + +try: + import cv2 as cv +except Exception as e: # pragma: no cover + raise RuntimeError( + "OpenCV (opencv-python) is required for dlclivegui.assets.ascii.\nInstall with: pip install opencv-python" + ) from e + +# Character ramps (dense -> sparse) +ASCII_RAMP_SIMPLE = "@%#*+=-:. " +ASCII_RAMP_FINE = "@$B%8&WM#*oahkbdpqwmZO0QLCJUYXzcvunxrjft/\\|()1{}[]?-_+~<>i!lI;:,\"^`'. " + +ColorMode = Literal["auto", "always", "never"] + +# ----------------------------- +# Terminal / ANSI capabilities +# ----------------------------- + + +def enable_windows_ansi_support() -> None: + """Enable ANSI escape support in Windows terminals. + Safe to call on any OS; no-op on non-Windows. + """ + if os.name == "nt": + # This call toggles the console mode to enable VT processing in many hosts + os.system("") + + +def get_terminal_width(default: int = 80) -> int: + """Return terminal width in columns, or a fallback if stdout is not a TTY.""" + try: + if not sys.stdout.isatty(): + return default + return shutil.get_terminal_size((default, 24)).columns + except Exception: + return default + + +def should_use_color(mode: ColorMode = "auto") -> bool: + """Determine if colored ANSI output should be emitted. + + - 'never': never use color + - 'always': always use color (even when redirected) + - 'auto': use color only when stdout is a TTY and NO_COLOR is not set + """ + if mode == "never": + return False + if mode == "always": + return True + # auto + if os.environ.get("NO_COLOR"): + return False + return sys.stdout.isatty() + + +def terminal_is_wide_enough(min_width: int = 60) -> bool: + if not sys.stdout.isatty(): + return False + return get_terminal_width() >= min_width + + +# ----------------------------- +# Image helpers +# ----------------------------- + + +def _to_bgr(img: np.ndarray) -> np.ndarray: + """Ensure an image array is 3-channel BGR.""" + if img.ndim == 2: + return cv.cvtColor(img, cv.COLOR_GRAY2BGR) + if img.ndim == 3 and img.shape[2] == 3: + return img + if img.ndim == 3 and img.shape[2] == 4: + # Caller should composite first; keep as-is for now + b, g, r, a = cv.split(img) + return cv.merge((b, g, r)) + raise ValueError(f"Unsupported image shape for BGR conversion: {img.shape!r}") + + +def composite_over_color(img: np.ndarray, bg_bgr: tuple[int, int, int] = (255, 255, 255)) -> np.ndarray: + """If img has alpha (BGRA), alpha-composite over a solid BGR color and return BGR.""" + if img.ndim == 3 and img.shape[2] == 4: + b, g, r, a = cv.split(img) + af = (a.astype(np.float32) / 255.0)[..., None] # (H,W,1) + bgr = cv.merge((b, g, r)).astype(np.float32) + bg = np.empty_like(bgr, dtype=np.float32) + bg[..., 0] = bg_bgr[0] + bg[..., 1] = bg_bgr[1] + bg[..., 2] = bg_bgr[2] + out = af * bgr + (1.0 - af) * bg + return np.clip(out, 0, 255).astype(np.uint8) + return _to_bgr(img) + + +def crop_to_content_alpha(img_bgra: np.ndarray, alpha_thresh: int = 1, pad: int = 0) -> np.ndarray: + """Crop to bounding box of pixels where alpha > alpha_thresh. Returns BGRA.""" + if not (img_bgra.ndim == 3 and img_bgra.shape[2] == 4): + return img_bgra + a = img_bgra[..., 3] + mask = a > alpha_thresh + if not mask.any(): + return img_bgra + ys, xs = np.where(mask) + y0, y1 = ys.min(), ys.max() + x0, x1 = xs.min(), xs.max() + if pad: + h, w = a.shape + y0 = max(0, y0 - pad) + x0 = max(0, x0 - pad) + y1 = min(h - 1, y1 + pad) + x1 = min(w - 1, x1 + pad) + return img_bgra[y0 : y1 + 1, x0 : x1 + 1, :] + + +def crop_to_content_bg( + img_bgr: np.ndarray, bg: Literal["white", "black"] = "white", tol: int = 10, pad: int = 0 +) -> np.ndarray: + """Heuristic crop when no alpha: assume uniform white or black background. + Returns BGR. + """ + if not (img_bgr.ndim == 3 and img_bgr.shape[2] == 3): + img_bgr = _to_bgr(img_bgr) + if bg == "white": + dist = 255 - img_bgr.max(axis=2) # darker than white + mask = dist > tol + else: + dist = img_bgr.max(axis=2) # brighter than black + mask = dist > tol + if not mask.any(): + return img_bgr + ys, xs = np.where(mask) + y0, y1 = ys.min(), ys.max() + x0, x1 = xs.min(), xs.max() + if pad: + h, w = mask.shape + y0 = max(0, y0 - pad) + x0 = max(0, x0 - pad) + y1 = min(h - 1, y1 + pad) + x1 = min(w - 1, x1 + pad) + return img_bgr[y0 : y1 + 1, x0 : x1 + 1, :] + + +def resize_for_terminal(img: np.ndarray, width: int | None, aspect: float | None) -> np.ndarray: + """Resize image for terminal display. + + Parameters + ---------- + width: target character width (None -> current terminal width) + aspect: character cell height/width ratio; default 0.5 is good for many fonts. + """ + h, w = img.shape[:2] + if width is None: + width = get_terminal_width(100) + width = max(20, int(width)) + if aspect is None: + # Allow override by env var, else default 0.5 + try: + aspect = float(os.environ.get("DLCLIVE_ASCII_ASPECT", "0.5")) + except ValueError: + aspect = 0.5 + new_h = max(1, int((h / w) * width * aspect)) + return cv.resize(img, (width, new_h), interpolation=cv.INTER_AREA) + + +# ----------------------------- +# Rendering +# ----------------------------- + + +def _map_luminance_to_chars(gray: np.ndarray, fine: bool) -> Iterable[str]: + ramp = ASCII_RAMP_FINE if fine else ASCII_RAMP_SIMPLE + idx = (gray.astype(np.float32) / 255.0 * (len(ramp) - 1)).astype(np.int32) + lines = ["".join(ramp[i] for i in row) for row in idx] + return lines + + +def _color_ascii_lines(img_bgr: np.ndarray, fine: bool, invert: bool) -> Iterable[str]: + ramp = ASCII_RAMP_FINE if fine else ASCII_RAMP_SIMPLE + b, g, r = cv.split(img_bgr) + lum = (0.0722 * b + 0.7152 * g + 0.2126 * r).astype(np.float32) + if invert: + lum = 255.0 - lum + idx = (lum / 255.0 * (len(ramp) - 1)).astype(np.int32) + h, w = idx.shape + lines = [] + for y in range(h): + seg = [] + for x in range(w): + ch = ramp[idx[y, x]] + bb, gg, rr = img_bgr[y, x] + seg.append(f"\x1b[38;2;{rr};{gg};{bb}m{ch}\x1b[0m") + lines.append("".join(seg)) + return lines + + +# ----------------------------- +# Public API +# ----------------------------- + + +def generate_ascii_lines( + image_path: str, + *, + width: int | None = None, + aspect: float | None = None, + color: ColorMode = "auto", + fine: bool = False, + invert: bool = False, + crop_content: bool = False, + crop_bg: Literal["none", "white", "black"] = "none", + alpha_thresh: int = 1, + crop_pad: int = 0, + bg_bgr: tuple[int, int, int] = (255, 255, 255), +) -> Iterable[str]: + """Load an image and return ASCII art lines sized for the user's terminal. + + Parameters + ---------- + image_path: path to the input image + width: output width in characters (None -> detect terminal width) + aspect: character cell height/width ratio (None -> 0.5 or env override) + color: 'auto'|'always'|'never' color mode + fine: use a finer 70+ character ramp + invert: invert luminance mapping + crop_content: crop to non-transparent content (alpha) if present + crop_bg: when no alpha, optionally crop assuming a uniform 'white' or 'black' background + alpha_thresh: threshold for alpha-based crop (0-255) + crop_pad: pixels of padding around detected content + bg_bgr: background color used for alpha compositing (default white) + """ + enable_windows_ansi_support() + + if not os.path.isfile(image_path): + raise FileNotFoundError(image_path) + + # Load preserving alpha if present + img = cv.imread(image_path, cv.IMREAD_UNCHANGED) + if img is None: + raise RuntimeError(f"Failed to load image with OpenCV: {image_path}") + + # Crop prior to compositing/resizing + if crop_content and img.ndim == 3 and img.shape[2] == 4: + img = crop_to_content_alpha(img, alpha_thresh=alpha_thresh, pad=crop_pad) + elif crop_content and (img.ndim != 3 or img.shape[2] != 4) and crop_bg in ("white", "black"): + img = crop_to_content_bg(_to_bgr(img), bg=crop_bg, tol=10, pad=crop_pad) + + # Composite transparency to solid background for correct visual result + img_bgr = composite_over_color(img, bg_bgr=bg_bgr) + + # Resize for terminal cell ratio + img_bgr = resize_for_terminal(img_bgr, width=width, aspect=aspect) + + use_color = should_use_color(color) + + if use_color: + return _color_ascii_lines(img_bgr, fine=fine, invert=invert) + else: + gray = cv.cvtColor(img_bgr, cv.COLOR_BGR2GRAY) + if invert: + gray = 255 - gray + return _map_luminance_to_chars(gray, fine=fine) + + +def print_ascii( + image_path: str, + *, + width: int | None = None, + aspect: float | None = None, + color: ColorMode = "auto", + fine: bool = False, + invert: bool = False, + crop_content: bool = False, + crop_bg: Literal["none", "white", "black"] = "none", + alpha_thresh: int = 1, + crop_pad: int = 0, + bg_bgr: tuple[int, int, int] = (255, 255, 255), + output: str | None = None, +) -> None: + """Convenience: generate and print ASCII art; optionally write it to a file.""" + lines = list( + generate_ascii_lines( + image_path, + width=width, + aspect=aspect, + color=color, + fine=fine, + invert=invert, + crop_content=crop_content, + crop_bg=crop_bg, + alpha_thresh=alpha_thresh, + crop_pad=crop_pad, + bg_bgr=bg_bgr, + ) + ) + + # Print to stdout + for line in lines: + print(line) + + # Optionally write raw ANSI/plain text to a file + if output: + with open(output, "w", encoding="utf-8", newline="\n") as f: + for line in lines: + f.write(line) + f.write("\n") + + +# ----------------------------- +# Optional: Help banner helpers +# ----------------------------- +ASCII_IMAGE_PATH = resources.files("dlclivegui.assets") / "logo_transparent.png" + + +def build_help_description( + static_banner: str | None = None, *, desc=None, color: ColorMode = "auto", min_width: int = 60 +) -> str: + """Return a help description string that conditionally includes a colored ASCII banner. + + - If stdout is a TTY and wide enough, returns banner + description. + - Otherwise returns a plain, single-line description. + - If static_banner is None, uses ASCII_BANNER (empty by default). + """ + enable_windows_ansi_support() + desc = "DeepLabCut-Live GUI — launch the graphical interface." if desc is None else desc + if static_banner is None: + banner = "\n".join( + generate_ascii_lines( + str(ASCII_IMAGE_PATH), + width=shutil.get_terminal_size((80, 24)).columns - 10, + aspect=0.5, + color=color, + fine=True, + invert=False, + crop_content=True, + crop_bg="white", + alpha_thresh=1, + crop_pad=1, + bg_bgr=(255, 255, 255), + ) + ) + else: + banner = static_banner + if banner and terminal_is_wide_enough(min_width=min_width): + if should_use_color(color): + banner = f"\x1b[36m{banner}\x1b[0m" + return banner + "\n" + desc + return desc diff --git a/dlclivegui/gui/theme.py b/dlclivegui/gui/theme.py index cdc9ace..cc6db38 100644 --- a/dlclivegui/gui/theme.py +++ b/dlclivegui/gui/theme.py @@ -10,7 +10,7 @@ from PySide6.QtWidgets import QApplication # ---- Splash screen config ---- -SHOW_SPLASH = True +SHOW_SPLASH = False SPLASH_SCREEN_WIDTH = 600 SPLASH_SCREEN_HEIGHT = 400 SPLASH_SCREEN_DURATION_MS = 1000 diff --git a/dlclivegui/main.py b/dlclivegui/main.py index 35494f7..0f05966 100644 --- a/dlclivegui/main.py +++ b/dlclivegui/main.py @@ -1,6 +1,7 @@ # dlclivegui/gui/main.py from __future__ import annotations +import argparse import logging import signal import sys @@ -9,6 +10,7 @@ from PySide6.QtGui import QIcon from PySide6.QtWidgets import QApplication +from dlclivegui.assets import ascii_art as art from dlclivegui.gui.main_window import DLCLiveMainWindow from dlclivegui.gui.misc.splash import SplashConfig, show_splash from dlclivegui.gui.theme import ( @@ -42,22 +44,33 @@ def _sigint_handler(_signum, _frame) -> None: signal.signal(signal.SIGINT, _sigint_handler) # Keepalive timer to allow Python to handle signals while Qt is running. - sig_timer = QTimer(app) + sig_timer = QTimer() sig_timer.setInterval(100) # 50–200ms typical; keep low overhead sig_timer.timeout.connect(lambda: None) sig_timer.start() - if not hasattr(app, "_sig_timer"): - app._sig_timer = sig_timer - else: - raise RuntimeError("QApplication already has _sig_timer attribute, which is reserved for SIGINT handling.") + if hasattr(app, "_sig_timer"): + app._sig_timer.stop() # Stop any existing timer to avoid duplicates + app._sig_timer = sig_timer # Store on app to keep it alive and allow cleanup on exit + + +def parse_args(argv=None): + parser = argparse.ArgumentParser( + description=art.build_help_description(), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument("--no-art", action="store_true", help="Disable ASCII art in help and when launching.") + return parser.parse_known_args(argv) def main() -> None: - # signal.signal(signal.SIGINT, signal.SIG_DFL) + args, _unknown = parse_args() # HiDPI pixmaps - always enabled in Qt 6 so no need to set it explicitly # QApplication.setAttribute(Qt.ApplicationAttribute.AA_UseHighDpiPixmaps, True) + print("Starting DeepLabCut-Live GUI...") + if not args.no_art: + print(art.build_help_description(desc="Welcome to DeepLabCut-Live GUI!")) app = QApplication(sys.argv) app.setWindowIcon(QIcon(LOGO)) diff --git a/tests/gui/test_app_entrypoint.py b/tests/gui/test_app_entrypoint.py index f28ddc3..bd3d4c5 100644 --- a/tests/gui/test_app_entrypoint.py +++ b/tests/gui/test_app_entrypoint.py @@ -16,6 +16,12 @@ def _import_fresh(): return importlib.import_module(MODULE_UNDER_TEST) +@pytest.fixture(autouse=True) +def set_use_splash_true(monkeypatch): + # Ensure thems.py SHOW_SPLASH is True for tests that rely on it, without affecting other tests + monkeypatch.setattr("dlclivegui.gui.theme.SHOW_SPLASH", True) + + @pytest.mark.gui def test_main_with_splash(monkeypatch): appmod = _import_fresh() From 36117740b0855ff0e2eb7bd8c8fc38c2c39da8f4 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Mon, 16 Feb 2026 22:35:39 +0100 Subject: [PATCH 08/21] Use develop mode in tox testenv --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index 3ac6e62..e16c20b 100644 --- a/tox.ini +++ b/tox.ini @@ -8,7 +8,7 @@ skip_missing_interpreters = true [testenv] description = Unit + smoke tests (exclude hardware) with coverage -package = wheel +use_develop = true extras = test # Keep behavior aligned with your GitHub Actions job: From 502e16e05a1040f8cc2e32221a36463e3f305faa Mon Sep 17 00:00:00 2001 From: C-Achard Date: Mon, 16 Feb 2026 22:50:15 +0100 Subject: [PATCH 09/21] Add ascii_art tests and update coverage omit Fix a small header comment in dlclivegui/assets/ascii_art.py and add dlclivegui/assets/* to coverage omit. Expand tests/gui/test_app_entrypoint.py with extensive unit tests for dlclivegui.assets.ascii_art (terminal/ANSI behavior, image helpers, rendering, generate/print API, and help banner), including TTY/NOTTY fixtures, image fixtures (using OpenCV, skipping if missing), and helpers for deterministic terminal sizing. Also adjust imports in the test to reference dlclivegui.assets.ascii_art. --- dlclivegui/assets/ascii_art.py | 2 +- pyproject.toml | 1 + tests/gui/test_app_entrypoint.py | 280 +++++++++++++++++++++++++++++++ 3 files changed, 282 insertions(+), 1 deletion(-) diff --git a/dlclivegui/assets/ascii_art.py b/dlclivegui/assets/ascii_art.py index 42d97d0..9f3ca76 100644 --- a/dlclivegui/assets/ascii_art.py +++ b/dlclivegui/assets/ascii_art.py @@ -11,7 +11,7 @@ Dependencies: opencv-python, numpy """ -# dlclivegui/assets/ascii.py +# dlclivegui/assets/ascii_art.py from __future__ import annotations import os diff --git a/pyproject.toml b/pyproject.toml index 716079a..ce12d5e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -133,6 +133,7 @@ exclude_lines = [ ] omit = [ "tests/*", + "dlclivegui/assets/*", ] [tool.coverage.run] branch = true diff --git a/tests/gui/test_app_entrypoint.py b/tests/gui/test_app_entrypoint.py index bd3d4c5..0d34a30 100644 --- a/tests/gui/test_app_entrypoint.py +++ b/tests/gui/test_app_entrypoint.py @@ -2,11 +2,19 @@ from __future__ import annotations import importlib +import os import sys +from pathlib import Path from unittest.mock import MagicMock +import numpy as np import pytest +# Import the module under test +# Adjust the import to your package layout as needed: +# from dlclivegui.assets import ascii as ascii_mod +import dlclivegui.assets.ascii_art as ascii_mod + MODULE_UNDER_TEST = "dlclivegui.main" @@ -128,3 +136,275 @@ def test_main_without_splash(monkeypatch): show_splash_mock.assert_not_called() assert calls["count"] == 0 win_instance.show.assert_called_once() + + +try: + import cv2 as cv +except Exception: + pytest.skip("OpenCV (opencv-python) is required for these tests.", allow_module_level=True) + + +# ------------------------- +# Fixtures & small helpers +# ------------------------- + + +@pytest.fixture +def tmp_png_gray(tmp_path: Path): + """Create a simple 16x8 gray gradient PNG without alpha.""" + h, w = 8, 16 + # Horizontal gradient from black to white in BGR + x = np.linspace(0, 255, w, dtype=np.uint8) + img = np.tile(x, (h, 1)) + bgr = cv.cvtColor(img, cv.COLOR_GRAY2BGR) + p = tmp_path / "gray.png" + assert cv.imwrite(str(p), bgr) + return p + + +@pytest.fixture +def tmp_png_bgra_logo(tmp_path: Path): + """Create a small BGRA image with a transparent border and opaque center.""" + h, w = 10, 20 + bgra = np.zeros((h, w, 4), dtype=np.uint8) + # Opaque blue rectangle in center + bgra[2:8, 5:15, 0] = 255 # B + bgra[2:8, 5:15, 3] = 255 # A + p = tmp_path / "logo_bgra.png" + assert cv.imwrite(str(p), bgra) + return p + + +def _force_isatty(monkeypatch, obj, value: bool): + """ + Ensure obj.isatty() returns value. + Try instance patch first; if the object disallows attribute assignment, + patch the method on its class. + """ + try: + # Try patching the instance directly + monkeypatch.setattr(obj, "isatty", lambda: value, raising=False) + except Exception: + # Fallback: patch the class method + cls = type(obj) + monkeypatch.setattr(cls, "isatty", lambda self: value, raising=True) + + +@pytest.fixture +def force_tty(monkeypatch): + """ + Pretend stdout is a TTY and provide a stable terminal size inside the + module-under-test namespace (and the actual sys). + """ + # NO_COLOR must be unset for should_use_color("auto") + monkeypatch.delenv("NO_COLOR", raising=False) + + # Make whatever stdout object exists report TTY=True + _force_isatty(monkeypatch, sys.stdout, True) + _force_isatty(monkeypatch, ascii_mod.sys.stdout, True) + + # Ensure terminal size used by the module is deterministic + monkeypatch.setattr( + ascii_mod.shutil, + "get_terminal_size", + lambda fallback=None: os.terminal_size((80, 24)), + raising=True, + ) + return sys.stdout # not used directly, but handy + + +@pytest.fixture +def force_notty(monkeypatch): + """ + Pretend stdout is not a TTY. + """ + _force_isatty(monkeypatch, sys.stdout, False) + _force_isatty(monkeypatch, ascii_mod.sys.stdout, False) + return sys.stdout + + +# ------------------------- +# Terminal / ANSI behavior +# ------------------------- + + +def test_get_terminal_width_tty(force_tty): + width = ascii_mod.get_terminal_width(default=123) + assert width == 80 + + +def test_get_terminal_width_notty(force_notty): + width = ascii_mod.get_terminal_width(default=123) + assert width == 123 + + +def test_should_use_color_auto_tty(force_tty, monkeypatch): + monkeypatch.delenv("NO_COLOR", raising=False) + assert ascii_mod.should_use_color("auto") is True + + +def test_should_use_color_auto_no_color(force_tty, monkeypatch): + monkeypatch.setenv("NO_COLOR", "1") + assert ascii_mod.should_use_color("auto") is False + + +def test_should_use_color_modes(force_notty): + assert ascii_mod.should_use_color("never") is False + assert ascii_mod.should_use_color("always") is True + + +def test_terminal_is_wide_enough(force_tty): + assert ascii_mod.terminal_is_wide_enough(60) is True + assert ascii_mod.terminal_is_wide_enough(100) is False + + +# ------------------------- +# Image helpers +# ------------------------- + + +def test__to_bgr_converts_gray(): + gray = np.zeros((5, 7), dtype=np.uint8) + bgr = ascii_mod._to_bgr(gray) + assert bgr.shape == (5, 7, 3) + assert bgr.dtype == np.uint8 + + +def test_composite_over_color_bgra(tmp_png_bgra_logo): + img = cv.imread(str(tmp_png_bgra_logo), cv.IMREAD_UNCHANGED) + assert img.shape[2] == 4 + bgr = ascii_mod.composite_over_color(img, bg_bgr=(10, 20, 30)) + assert bgr.shape[2] == 3 + # Transparent border should become the bg color + assert tuple(bgr[0, 0]) == (10, 20, 30) + # Opaque center should keep blue channel high + assert bgr[5, 10, 0] == 255 + + +def test_crop_to_content_alpha(tmp_png_bgra_logo): + img = cv.imread(str(tmp_png_bgra_logo), cv.IMREAD_UNCHANGED) + cropped = ascii_mod.crop_to_content_alpha(img, alpha_thresh=1, pad=0) + h, w = cropped.shape[:2] + assert h == 6 # 2..7 -> 6 rows + assert w == 10 # 5..14 -> 10 cols + assert cropped[..., 3].min() == 255 + + +def test_crop_to_content_bg_white(tmp_path): + # Create white background with a black rectangle + h, w = 12, 20 + bgr = np.full((h, w, 3), 255, dtype=np.uint8) + bgr[3:10, 4:15] = 0 + p = tmp_path / "white_bg.png" + assert cv.imwrite(str(p), bgr) + cropped = ascii_mod.crop_to_content_bg(bgr, bg="white", tol=10, pad=0) + assert cropped.shape[0] == 7 # 3..9 -> 7 rows + assert cropped.shape[1] == 11 # 4..14 -> 11 cols + + +def test_resize_for_terminal_aspect_env(monkeypatch): + img = np.zeros((100, 200, 3), dtype=np.uint8) + monkeypatch.setenv("DLCLIVE_ASCII_ASPECT", "0.25") + resized = ascii_mod.resize_for_terminal(img, width=80, aspect=None) + # new_h = (h/w) * width * aspect = (100/200)*80*0.25 = 10 + assert resized.shape[:2] == (10, 80) + + +# ------------------------- +# Rendering +# ------------------------- + + +def test_map_luminance_to_chars_simple(): + gray = np.array([[0, 127, 255]], dtype=np.uint8) + lines = list(ascii_mod._map_luminance_to_chars(gray, fine=False)) + assert len(lines) == 1 + # First char should be the densest in the simple ramp '@', last should be space + assert lines[0][0] == ascii_mod.ASCII_RAMP_SIMPLE[0] + assert lines[0][-1] == ascii_mod.ASCII_RAMP_SIMPLE[-1] + + +def test_color_ascii_lines_basic(): + # Small 2x3 color blocks + img = np.zeros((2, 3, 3), dtype=np.uint8) + img[:] = (10, 20, 30) + lines = list(ascii_mod._color_ascii_lines(img, fine=False, invert=False)) + assert len(lines) == 2 + # Expect ANSI 24-bit color sequence present + assert "\x1b[38;2;" in lines[0] + # Reset code present + assert lines[0].endswith("\x1b[0m" * 3) is False # individual chars have resets, but line won't end with triple + + +# ------------------------- +# Public API: generate & print +# ------------------------- + + +@pytest.mark.parametrize("use_color", ["never", "always"]) +def test_generate_ascii_lines_gray(tmp_png_gray, use_color, force_tty): + lines = list( + ascii_mod.generate_ascii_lines( + str(tmp_png_gray), + width=40, + aspect=0.5, + color=use_color, + fine=False, + invert=False, + crop_content=False, + crop_bg="none", + ) + ) + assert len(lines) > 0 + # Width equals number of characters per line + assert all(len(line) == 40 or ("\x1b[38;2;" in line and len(_strip_ansi(line)) == 40) for line in lines) + + +def _strip_ansi(s: str) -> str: + import re + + return re.sub(r"\x1b\[[0-9;]*m", "", s) + + +def test_generate_ascii_lines_crop_alpha(tmp_png_bgra_logo, force_tty): + lines_no_crop = list( + ascii_mod.generate_ascii_lines(str(tmp_png_bgra_logo), width=40, aspect=0.5, color="never", crop_content=False) + ) + lines_crop = list( + ascii_mod.generate_ascii_lines(str(tmp_png_bgra_logo), width=40, aspect=0.5, color="never", crop_content=True) + ) + # Both are non-empty; height may change either way depending on aspect ratio + assert len(lines_no_crop) > 0 and len(lines_crop) > 0 + # Optional: assert they differ (most likely); comment out if flakiness observed + assert len(lines_crop) != len(lines_no_crop) + + +def test_print_ascii_writes_file(tmp_png_gray, force_tty, tmp_path): + out_path = tmp_path / "out.txt" + ascii_mod.print_ascii( + str(tmp_png_gray), + width=30, + aspect=0.5, + color="never", + output=str(out_path), + ) + assert out_path.exists() + text = out_path.read_text(encoding="utf-8") + # Expect multiple lines of length 30 + lines = [ln for ln in text.splitlines() if ln] + assert len(lines) > 0 + assert all(len(ln) == 30 for ln in lines) + + +def test_build_help_description_tty(tmp_png_bgra_logo, monkeypatch, force_tty): + monkeypatch.setattr(ascii_mod, "ASCII_IMAGE_PATH", Path(tmp_png_bgra_logo)) + desc = ascii_mod.build_help_description(static_banner=None, color="always", min_width=60) + assert "DeepLabCut-Live GUI" in desc + assert "\x1b[36m" in desc # cyan wrapper now present since TTY is mocked correctly + + +def test_build_help_description_notty(tmp_png_bgra_logo, monkeypatch, force_notty): + monkeypatch.setattr(ascii_mod, "ASCII_IMAGE_PATH", Path(tmp_png_bgra_logo)) + desc = ascii_mod.build_help_description(static_banner=None, color="always", min_width=60) + # Not a TTY -> no banner, just the plain description + assert desc.strip() == "DeepLabCut-Live GUI — launch the graphical interface." From d79eec4a9f2afb97dc194fb28781dbad08745996 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Tue, 17 Feb 2026 07:57:24 +0100 Subject: [PATCH 10/21] Revert "Use develop mode in tox testenv" This reverts commit 36117740b0855ff0e2eb7bd8c8fc38c2c39da8f4. --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index e16c20b..3ac6e62 100644 --- a/tox.ini +++ b/tox.ini @@ -8,7 +8,7 @@ skip_missing_interpreters = true [testenv] description = Unit + smoke tests (exclude hardware) with coverage -use_develop = true +package = wheel extras = test # Keep behavior aligned with your GitHub Actions job: From de9de7f423af4a831a6f2d752d2e423bac68f0c3 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Tue, 17 Feb 2026 08:17:20 +0100 Subject: [PATCH 11/21] Bump actions and harden CLI smoke test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update GitHub Actions usages to actions/checkout@v6 and actions/setup-python@v6 in format and python-package workflows for consistency and newer runner support. Improve the python-package workflow's CLI smoke test to explicitly check for the 'dlclivegui' entry point, run its --help when present, and fail the job if the help command returns non-zero—providing clearer diagnostics instead of silently continuing. --- .github/workflows/format.yml | 4 ++-- .github/workflows/python-package.yml | 15 +++++++++++---- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index a226ae0..9858a21 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -10,12 +10,12 @@ jobs: pre_commit_checks: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v6 with: fetch-depth: 0 ref: ${{ github.head_ref }} - - uses: actions/setup-python@v4 + - uses: actions/setup-python@v6 with: python-version: '3.10' diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 077d303..c354f24 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -25,10 +25,10 @@ jobs: python-version: [ "3.10", "3.11", "3.12" ] # adjust to what you support steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} @@ -71,8 +71,15 @@ jobs: print("Imported:", m.__name__, "version:", getattr(m, "__version__", "n/a")) PY - # Console entry point best-effort check (adjust name if different) - (command -v dlclivegui && dlclivegui --help) || echo "CLI not available or returned non-zero; continuing." + if ! command -v dlclivegui >/dev/null 2>&1; then + echo "CLI entry point 'dlclivegui' not found in PATH; skipping CLI smoke test." + else + echo "Running 'dlclivegui --help' smoke test..." + if ! dlclivegui --help >/dev/null 2>&1; then + echo "::error::'dlclivegui --help' failed; this indicates a problem with the installed CLI package." + exit 1 + fi + fi publish: name: Publish to PyPI From 7f5b75b66bb6a3d083032bd4f51237648f64cb40 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Tue, 17 Feb 2026 08:17:55 +0100 Subject: [PATCH 12/21] Move version to package and mark pyproject dynamic Add a __version__ = "2.0.0rc0" placeholder in dlclivegui/__init__.py and update pyproject.toml to use dynamic = ["version"] instead of a hardcoded version field. This makes the project metadata pull the version from the package source rather than storing it twice. --- dlclivegui/__init__.py | 1 + pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/dlclivegui/__init__.py b/dlclivegui/__init__.py index 9cc2640..f302f5b 100644 --- a/dlclivegui/__init__.py +++ b/dlclivegui/__init__.py @@ -24,3 +24,4 @@ "CameraConfigDialog", "main", ] +__version__ = "2.0.0rc0" # PLACEHOLDER diff --git a/pyproject.toml b/pyproject.toml index ce12d5e..8b28f5d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,6 @@ requires = [ "setuptools>=68" ] [project] name = "deeplabcut-live-gui" -version = "2.0.0rc0" # PLACEHOLDER description = "PySide6-based GUI to run real time pose estimation experiments with DeepLabCut" readme = "README.md" keywords = [ "deep learning", "deeplabcut", "gui", "pose estimation", "real-time" ] @@ -22,6 +21,7 @@ classifiers = [ "Programming Language :: Python :: 3.12", "Topic :: Scientific/Engineering :: Artificial Intelligence", ] +dynamic = [ "version" ] # version is set in dlclivegui/__init__.py dependencies = [ "cv2-enumerate-cameras", "deeplabcut-live==1.1", From 6d21bd2215f535d62d6d7c35475c9c4ff48a9e89 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Tue, 17 Feb 2026 08:18:27 +0100 Subject: [PATCH 13/21] Handle ASCII art errors & use logging Wrap ASCII-art generation in a try/except to avoid crashing when the asset or terminal ops fail (catch FileNotFoundError, RuntimeError, OSError and fall back to no banner). Add a clarifying comment for the Windows ANSI os.system("") workaround. In argument parsing, allow parse_args to accept argv, detect --no-art early, safely build the help description with a fallback default and log a warning on failure. Replace startup prints with logging.info and use the computed description for argparse. These changes make startup more robust and avoid surprising exceptions when ASCII art cannot be produced. --- dlclivegui/assets/ascii_art.py | 34 +++++++++++++++++++--------------- dlclivegui/main.py | 20 +++++++++++++++++--- 2 files changed, 36 insertions(+), 18 deletions(-) diff --git a/dlclivegui/assets/ascii_art.py b/dlclivegui/assets/ascii_art.py index 9f3ca76..2009734 100644 --- a/dlclivegui/assets/ascii_art.py +++ b/dlclivegui/assets/ascii_art.py @@ -47,7 +47,8 @@ def enable_windows_ansi_support() -> None: """ if os.name == "nt": # This call toggles the console mode to enable VT processing in many hosts - os.system("") + # Always leave the string empty. + os.system("") # This is a known, safe workaround to enable ANSI support on Windows. def get_terminal_width(default: int = 80) -> int: @@ -347,21 +348,24 @@ def build_help_description( enable_windows_ansi_support() desc = "DeepLabCut-Live GUI — launch the graphical interface." if desc is None else desc if static_banner is None: - banner = "\n".join( - generate_ascii_lines( - str(ASCII_IMAGE_PATH), - width=shutil.get_terminal_size((80, 24)).columns - 10, - aspect=0.5, - color=color, - fine=True, - invert=False, - crop_content=True, - crop_bg="white", - alpha_thresh=1, - crop_pad=1, - bg_bgr=(255, 255, 255), + try: + banner = "\n".join( + generate_ascii_lines( + str(ASCII_IMAGE_PATH), + width=shutil.get_terminal_size((80, 24)).columns - 10, + aspect=0.5, + color=color, + fine=True, + invert=False, + crop_content=True, + crop_bg="white", + alpha_thresh=1, + crop_pad=1, + bg_bgr=(255, 255, 255), + ) ) - ) + except (FileNotFoundError, RuntimeError, OSError): + banner = None else: banner = static_banner if banner and terminal_is_wide_enough(min_width=min_width): diff --git a/dlclivegui/main.py b/dlclivegui/main.py index 0f05966..04d55cf 100644 --- a/dlclivegui/main.py +++ b/dlclivegui/main.py @@ -55,8 +55,22 @@ def _sigint_handler(_signum, _frame) -> None: def parse_args(argv=None): + if argv is None: + argv = sys.argv[1:] + + default_desc = "Welcome to DeepLabCut-Live GUI!" + no_art_flag = "--no-art" in argv + if not no_art_flag: + try: + desc = art.build_help_description() + except Exception as e: + logging.warning(f"Failed to build ASCII art for help description: {e}") + desc = default_desc + else: + desc = default_desc + parser = argparse.ArgumentParser( - description=art.build_help_description(), + description=desc, formatter_class=argparse.RawDescriptionHelpFormatter, ) parser.add_argument("--no-art", action="store_true", help="Disable ASCII art in help and when launching.") @@ -68,9 +82,9 @@ def main() -> None: # HiDPI pixmaps - always enabled in Qt 6 so no need to set it explicitly # QApplication.setAttribute(Qt.ApplicationAttribute.AA_UseHighDpiPixmaps, True) - print("Starting DeepLabCut-Live GUI...") + logging.info("Starting DeepLabCut-Live GUI...") if not args.no_art: - print(art.build_help_description(desc="Welcome to DeepLabCut-Live GUI!")) + logging.info(art.build_help_description(desc="Welcome to DeepLabCut-Live GUI!")) app = QApplication(sys.argv) app.setWindowIcon(QIcon(LOGO)) From 756836ba05610d2472cda22d8c3f5d861fe57aa9 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Tue, 17 Feb 2026 08:19:05 +0100 Subject: [PATCH 14/21] Use fixtures to control SHOW_SPLASH in tests Replace the autouse fixture with explicit fixtures to toggle dlclivegui.gui.theme.SHOW_SPLASH per-test. Adds set_use_splash_true and set_use_splash_false fixtures, updates tests to accept them, and removes the manual SHOW_SPLASH override in test_main_without_splash. Also fixes a comment typo (thems.py -> theme.py) to make splash behavior explicit and avoid cross-test side effects. --- tests/gui/test_app_entrypoint.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/tests/gui/test_app_entrypoint.py b/tests/gui/test_app_entrypoint.py index 0d34a30..26b3315 100644 --- a/tests/gui/test_app_entrypoint.py +++ b/tests/gui/test_app_entrypoint.py @@ -24,14 +24,20 @@ def _import_fresh(): return importlib.import_module(MODULE_UNDER_TEST) -@pytest.fixture(autouse=True) +@pytest.fixture def set_use_splash_true(monkeypatch): - # Ensure thems.py SHOW_SPLASH is True for tests that rely on it, without affecting other tests + # Ensure theme.py SHOW_SPLASH is True for tests that rely on it, without affecting other tests monkeypatch.setattr("dlclivegui.gui.theme.SHOW_SPLASH", True) +@pytest.fixture +def set_use_splash_false(monkeypatch): + # Ensure theme.py SHOW_SPLASH is False for tests that rely on it, without affecting other tests + monkeypatch.setattr("dlclivegui.gui.theme.SHOW_SPLASH", False) + + @pytest.mark.gui -def test_main_with_splash(monkeypatch): +def test_main_with_splash(monkeypatch, set_use_splash_true): appmod = _import_fresh() # --- Patch Qt app & icon in the entry module's namespace --- @@ -101,7 +107,7 @@ def immediate_single_shot(ms, fn): @pytest.mark.gui -def test_main_without_splash(monkeypatch): +def test_main_without_splash(monkeypatch, set_use_splash_false): appmod = _import_fresh() # Patch Qt app creation & window icon @@ -111,9 +117,6 @@ def test_main_without_splash(monkeypatch): monkeypatch.setattr(appmod, "QApplication", QApplication_cls) monkeypatch.setattr(appmod, "QIcon", MagicMock(name="QIcon")) - # Force the no-splash branch - appmod.SHOW_SPLASH = False - # show_splash should not be called show_splash_mock = MagicMock(name="show_splash") monkeypatch.setattr(appmod, "show_splash", show_splash_mock) From 6893b69ab3ad93eb699c8e0846da83fc0ec0da11 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Tue, 17 Feb 2026 08:28:54 +0100 Subject: [PATCH 15/21] Vectorize mapping and add color cache for ASCII Improve ASCII art rendering performance by replacing per-pixel Python loops with vectorized operations and a byte-level cache. _map_luminance_to_chars now uses a numpy char LUT and joins rows from a (H,W) array of 1-char strings. _color_ascii_lines avoids cv.split and per-pixel f-strings: it computes luminance in float, uses a uint16 index map, packs RGB into a uint32 color key, and caches formatted ANSI-colored byte sequences keyed by (color<<8)|idx. Lines are built as bytearrays and decoded once. Functionality/formatting is preserved while reducing Python-level overhead. --- dlclivegui/assets/ascii_art.py | 65 ++++++++++++++++++++++++++++------ 1 file changed, 54 insertions(+), 11 deletions(-) diff --git a/dlclivegui/assets/ascii_art.py b/dlclivegui/assets/ascii_art.py index 2009734..039f9a0 100644 --- a/dlclivegui/assets/ascii_art.py +++ b/dlclivegui/assets/ascii_art.py @@ -194,27 +194,70 @@ def resize_for_terminal(img: np.ndarray, width: int | None, aspect: float | None def _map_luminance_to_chars(gray: np.ndarray, fine: bool) -> Iterable[str]: ramp = ASCII_RAMP_FINE if fine else ASCII_RAMP_SIMPLE + ramp_arr = np.array(list(ramp), dtype=" Iterable[str]: ramp = ASCII_RAMP_FINE if fine else ASCII_RAMP_SIMPLE - b, g, r = cv.split(img_bgr) - lum = (0.0722 * b + 0.7152 * g + 0.2126 * r).astype(np.float32) + ramp_bytes = [c.encode("utf-8") for c in ramp] # 1-byte ASCII in practice + reset = b"\x1b[0m" + + # luminance in float32 like your current code + b = img_bgr[..., 0].astype(np.float32) + g = img_bgr[..., 1].astype(np.float32) + r = img_bgr[..., 2].astype(np.float32) + lum = 0.0722 * b + 0.7152 * g + 0.2126 * r if invert: lum = 255.0 - lum - idx = (lum / 255.0 * (len(ramp) - 1)).astype(np.int32) + + idx = (lum / 255.0 * (len(ramp) - 1)).astype(np.uint16) # small dtype is fine + + # Pack color into one int: 0xRRGGBB (faster dict key than tuple) + rr = img_bgr[..., 2].astype(np.uint32) + gg = img_bgr[..., 1].astype(np.uint32) + bb = img_bgr[..., 0].astype(np.uint32) + color_key = (rr << 16) | (gg << 8) | bb # (H,W) uint32 + + # Cache: (color_key<<8)|idx -> bytes for full colored char INCLUDING reset + cache: dict[int, bytes] = {} + h, w = idx.shape - lines = [] + lines: list[str] = [] + for y in range(h): - seg = [] + ba = bytearray() + ck_row = color_key[y] + idx_row = idx[y] + img_bgr[y] # for extracting r/g/b when cache miss + for x in range(w): - ch = ramp[idx[y, x]] - bb, gg, rr = img_bgr[y, x] - seg.append(f"\x1b[38;2;{rr};{gg};{bb}m{ch}\x1b[0m") - lines.append("".join(seg)) + ik = int(idx_row[x]) + ck = int(ck_row[x]) + subkey = (ck << 8) | ik + + piece = cache.get(subkey) + if piece is None: + # Decode r,g,b from packed key (same as current rr,gg,bb) + rr_i = (ck >> 16) & 255 + gg_i = (ck >> 8) & 255 + bb_i = ck & 255 + + # EXACT same formatting as before + # \x1b[38;2;{rr};{gg};{bb}m{ch}\x1b[0m + prefix = f"\x1b[38;2;{rr_i};{gg_i};{bb_i}m".encode("ascii") + piece = prefix + ramp_bytes[ik] + reset + cache[subkey] = piece + + ba.extend(piece) + + lines.append(ba.decode("utf-8", errors="strict")) + return lines From 6d7a3e683b1433c74ab8c566d24000298d73b3da Mon Sep 17 00:00:00 2001 From: C-Achard Date: Tue, 17 Feb 2026 09:31:29 +0100 Subject: [PATCH 16/21] Add setuptools dynamic version & refine tests Add a setuptools dynamic version entry in pyproject.toml so package version is taken from dlclivegui.__version__. Also include package-data for dlclivegui.assets. Refactor tests/gui/test_app_entrypoint.py to import cv2 at module level and skip the test module early when OpenCV is not available (removed a duplicated try/except). Tighten the assertion in test_generate_ascii_lines_crop_alpha to compare the actual generated ASCII content (lines) rather than only lengths, and clean up related comments. --- pyproject.toml | 4 +++- tests/gui/test_app_entrypoint.py | 18 +++++++----------- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 8b28f5d..6241705 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -72,9 +72,11 @@ Documentation = "https://github.com/DeepLabCut/DeepLabCut-live-GUI" # FIXME @C- Homepage = "https://github.com/DeepLabCut/DeepLabCut-live-GUI" Repository = "https://github.com/DeepLabCut/DeepLabCut-live-GUI" +[tool.setuptools.dynamic] +version = { attr = "dlclivegui.__version__" } + # [tool.setuptools] # include-package-data = true - [tool.setuptools.package-data] "dlclivegui.assets" = [ "*.png" ] [tool.setuptools.packages] diff --git a/tests/gui/test_app_entrypoint.py b/tests/gui/test_app_entrypoint.py index 26b3315..12007df 100644 --- a/tests/gui/test_app_entrypoint.py +++ b/tests/gui/test_app_entrypoint.py @@ -10,9 +10,11 @@ import numpy as np import pytest -# Import the module under test -# Adjust the import to your package layout as needed: -# from dlclivegui.assets import ascii as ascii_mod +try: + import cv2 as cv +except Exception: + pytest.skip("OpenCV (opencv-python) is required for these tests.", allow_module_level=True) + import dlclivegui.assets.ascii_art as ascii_mod MODULE_UNDER_TEST = "dlclivegui.main" @@ -141,12 +143,6 @@ def test_main_without_splash(monkeypatch, set_use_splash_false): win_instance.show.assert_called_once() -try: - import cv2 as cv -except Exception: - pytest.skip("OpenCV (opencv-python) is required for these tests.", allow_module_level=True) - - # ------------------------- # Fixtures & small helpers # ------------------------- @@ -378,8 +374,8 @@ def test_generate_ascii_lines_crop_alpha(tmp_png_bgra_logo, force_tty): ) # Both are non-empty; height may change either way depending on aspect ratio assert len(lines_no_crop) > 0 and len(lines_crop) > 0 - # Optional: assert they differ (most likely); comment out if flakiness observed - assert len(lines_crop) != len(lines_no_crop) + # Cropping should affect the generated ASCII content + assert lines_crop != lines_no_crop def test_print_ascii_writes_file(tmp_png_gray, force_tty, tmp_path): From 7230f696d1420e4ec3d22747a0293078fa7787c3 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Tue, 17 Feb 2026 09:34:44 +0100 Subject: [PATCH 17/21] Optimize color ASCII rendering and fix module name Fix the OpenCV error message to reference dlclivegui.assets.ascii_art and refactor _color_ascii_lines for performance and clarity. Changes include: encode ASCII ramp once, clarify luminance comment, pack colors into 0xRRGGBB, use a prefix_cache for ANSI SGR color prefixes, emit a color sequence only when the color changes (using prev_ck), use memoryview for row access, append a single reset per line, and simplify character emission. Functionality and ANSI formatting are preserved while reducing per-character allocations and cache complexity. --- dlclivegui/assets/ascii_art.py | 59 ++++++++++++++++++---------------- 1 file changed, 32 insertions(+), 27 deletions(-) diff --git a/dlclivegui/assets/ascii_art.py b/dlclivegui/assets/ascii_art.py index 039f9a0..6359134 100644 --- a/dlclivegui/assets/ascii_art.py +++ b/dlclivegui/assets/ascii_art.py @@ -27,7 +27,7 @@ import cv2 as cv except Exception as e: # pragma: no cover raise RuntimeError( - "OpenCV (opencv-python) is required for dlclivegui.assets.ascii.\nInstall with: pip install opencv-python" + "OpenCV (opencv-python) is required for dlclivegui.assets.ascii_art.\nInstall with: pip install opencv-python" ) from e # Character ramps (dense -> sparse) @@ -205,10 +205,12 @@ def _map_luminance_to_chars(gray: np.ndarray, fine: bool) -> Iterable[str]: def _color_ascii_lines(img_bgr: np.ndarray, fine: bool, invert: bool) -> Iterable[str]: ramp = ASCII_RAMP_FINE if fine else ASCII_RAMP_SIMPLE - ramp_bytes = [c.encode("utf-8") for c in ramp] # 1-byte ASCII in practice + # ramp is ASCII; encode once + ramp_bytes = [c.encode("utf-8") for c in ramp] + reset = b"\x1b[0m" - # luminance in float32 like your current code + # Luminance (same coefficients you used; keep exact behavior) b = img_bgr[..., 0].astype(np.float32) g = img_bgr[..., 1].astype(np.float32) r = img_bgr[..., 2].astype(np.float32) @@ -216,45 +218,48 @@ def _color_ascii_lines(img_bgr: np.ndarray, fine: bool, invert: bool) -> Iterabl if invert: lum = 255.0 - lum - idx = (lum / 255.0 * (len(ramp) - 1)).astype(np.uint16) # small dtype is fine + idx = (lum / 255.0 * (len(ramp) - 1)).astype(np.uint16) - # Pack color into one int: 0xRRGGBB (faster dict key than tuple) + # Pack color into 0xRRGGBB for fast comparisons rr = img_bgr[..., 2].astype(np.uint32) gg = img_bgr[..., 1].astype(np.uint32) bb = img_bgr[..., 0].astype(np.uint32) color_key = (rr << 16) | (gg << 8) | bb # (H,W) uint32 - # Cache: (color_key<<8)|idx -> bytes for full colored char INCLUDING reset - cache: dict[int, bytes] = {} + # Cache SGR prefixes by packed color + # e.g. 0xRRGGBB -> b"\x1b[38;2;R;G;Bm" + prefix_cache: dict[int, bytes] = {} h, w = idx.shape lines: list[str] = [] for y in range(h): ba = bytearray() - ck_row = color_key[y] - idx_row = idx[y] - img_bgr[y] # for extracting r/g/b when cache miss + + ck_row = memoryview(color_key[y]) + idx_row = memoryview(idx[y]) + + prev_ck: int | None = None for x in range(w): - ik = int(idx_row[x]) ck = int(ck_row[x]) - subkey = (ck << 8) | ik - - piece = cache.get(subkey) - if piece is None: - # Decode r,g,b from packed key (same as current rr,gg,bb) - rr_i = (ck >> 16) & 255 - gg_i = (ck >> 8) & 255 - bb_i = ck & 255 - - # EXACT same formatting as before - # \x1b[38;2;{rr};{gg};{bb}m{ch}\x1b[0m - prefix = f"\x1b[38;2;{rr_i};{gg_i};{bb_i}m".encode("ascii") - piece = prefix + ramp_bytes[ik] + reset - cache[subkey] = piece - - ba.extend(piece) + + # Emit new color code only when color changes + if ck != prev_ck: + prefix = prefix_cache.get(ck) + if prefix is None: + rr_i = (ck >> 16) & 255 + gg_i = (ck >> 8) & 255 + bb_i = ck & 255 + prefix = f"\x1b[38;2;{rr_i};{gg_i};{bb_i}m".encode("ascii") + prefix_cache[ck] = prefix + ba.extend(prefix) + prev_ck = ck + + ba.extend(ramp_bytes[int(idx_row[x])]) + + # Reset once per line to prevent color bleed into subsequent terminal output + ba.extend(reset) lines.append(ba.decode("utf-8", errors="strict")) From 4c5f8e114da65630affcf88aa06d71a7b600c610 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Thu, 19 Feb 2026 16:23:59 +0100 Subject: [PATCH 18/21] Bump GitHub Actions and refine help banner Update CI steps to use actions/checkout@v6 and actions/setup-python@v6. Revise ASCII help banner generation: import LOGO_ALPHA from the theme, remove static resource path, add max_width parameter, compute banner width from terminal size (clamped between min_width and max_width), and skip rendering the banner when stdout is not a TTY. Simplifies banner selection logic and keeps colored banner output when available. --- .github/workflows/python-package.yml | 4 ++-- dlclivegui/assets/ascii_art.py | 25 ++++++++++++++++--------- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index c354f24..3640f00 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -88,10 +88,10 @@ jobs: if: ${{ startsWith(github.ref, 'refs/tags/v') }} # only on tag pushes like v1.2.3 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.x" diff --git a/dlclivegui/assets/ascii_art.py b/dlclivegui/assets/ascii_art.py index 6359134..51a8d2f 100644 --- a/dlclivegui/assets/ascii_art.py +++ b/dlclivegui/assets/ascii_art.py @@ -18,11 +18,12 @@ import shutil import sys from collections.abc import Iterable -from importlib import resources from typing import Literal import numpy as np +from dlclivegui.gui.theme import LOGO_ALPHA + try: import cv2 as cv except Exception as e: # pragma: no cover @@ -381,11 +382,10 @@ def print_ascii( # ----------------------------- # Optional: Help banner helpers # ----------------------------- -ASCII_IMAGE_PATH = resources.files("dlclivegui.assets") / "logo_transparent.png" def build_help_description( - static_banner: str | None = None, *, desc=None, color: ColorMode = "auto", min_width: int = 60 + static_banner: str | None = None, *, desc=None, color: ColorMode = "auto", min_width: int = 60, max_width: int = 120 ) -> str: """Return a help description string that conditionally includes a colored ASCII banner. @@ -395,12 +395,20 @@ def build_help_description( """ enable_windows_ansi_support() desc = "DeepLabCut-Live GUI — launch the graphical interface." if desc is None else desc - if static_banner is None: + if not sys.stdout.isatty() and terminal_is_wide_enough(min_width=min_width): + return desc + + banner: str | None + if static_banner is not None: + banner = static_banner + else: try: + term_width = get_terminal_width(default=max_width) + width = max(min(term_width, max_width), min_width) banner = "\n".join( generate_ascii_lines( - str(ASCII_IMAGE_PATH), - width=shutil.get_terminal_size((80, 24)).columns - 10, + str(LOGO_ALPHA), + width=width, aspect=0.5, color=color, fine=True, @@ -414,9 +422,8 @@ def build_help_description( ) except (FileNotFoundError, RuntimeError, OSError): banner = None - else: - banner = static_banner - if banner and terminal_is_wide_enough(min_width=min_width): + + if banner: if should_use_color(color): banner = f"\x1b[36m{banner}\x1b[0m" return banner + "\n" + desc From 0a72842e0747d4f77c1f98a9b369670a903a4ec2 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Thu, 19 Feb 2026 16:28:30 +0100 Subject: [PATCH 19/21] Show banner only for help in TTY Avoid building the ASCII/banner art unless help is being requested: parse_args now detects -h/--help and only constructs the help description in that case. At startup, the banner is printed (not logged) only when stdout is a TTY and the terminal is wide enough; failures while building/printing the banner are caught to keep startup robust. Also removed dlclivegui/assets/* from the coverage omit list so those files will be considered by coverage tooling. --- dlclivegui/main.py | 17 ++++++++++++----- pyproject.toml | 1 - 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/dlclivegui/main.py b/dlclivegui/main.py index 04d55cf..eb444aa 100644 --- a/dlclivegui/main.py +++ b/dlclivegui/main.py @@ -60,7 +60,10 @@ def parse_args(argv=None): default_desc = "Welcome to DeepLabCut-Live GUI!" no_art_flag = "--no-art" in argv - if not no_art_flag: + wants_help = any(a in ("-h", "--help") for a in argv) + + # Only build banner description if we're about to print help + if wants_help and not no_art_flag: try: desc = art.build_help_description() except Exception as e: @@ -80,11 +83,15 @@ def parse_args(argv=None): def main() -> None: args, _unknown = parse_args() - # HiDPI pixmaps - always enabled in Qt 6 so no need to set it explicitly - # QApplication.setAttribute(Qt.ApplicationAttribute.AA_UseHighDpiPixmaps, True) logging.info("Starting DeepLabCut-Live GUI...") - if not args.no_art: - logging.info(art.build_help_description(desc="Welcome to DeepLabCut-Live GUI!")) + + # If you want a startup banner, PRINT it (not log), and only in TTY contexts. + if not args.no_art and sys.stdout.isatty() and art.terminal_is_wide_enough(): + try: + print(art.build_help_description(desc="Welcome to DeepLabCut-Live GUI!")) + except Exception: + # Keep startup robust; don't fail if banner fails + pass app = QApplication(sys.argv) app.setWindowIcon(QIcon(LOGO)) diff --git a/pyproject.toml b/pyproject.toml index 6241705..7fba79a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -135,7 +135,6 @@ exclude_lines = [ ] omit = [ "tests/*", - "dlclivegui/assets/*", ] [tool.coverage.run] branch = true From cf51c1f66201859fddbd4f772e94ba54a3f2dcac Mon Sep 17 00:00:00 2001 From: C-Achard Date: Thu, 19 Feb 2026 16:33:23 +0100 Subject: [PATCH 20/21] Move ascii_art tests to new file and require cv2 Extract the ASCII-art-related fixtures and tests from tests/gui/test_app_entrypoint.py into a new tests/gui/test_ascii_art.py to separate concerns and improve test organization. Replace pytest.skip on missing OpenCV with raising ImportError so tests fail fast when cv2 is not installed (ensures main deps are present). Keep a small assertion fix in the original file (win_instance.show.assert_called_once()). --- tests/gui/test_app_entrypoint.py | 276 ------------------------------ tests/gui/test_ascii_art.py | 278 +++++++++++++++++++++++++++++++ 2 files changed, 278 insertions(+), 276 deletions(-) create mode 100644 tests/gui/test_ascii_art.py diff --git a/tests/gui/test_app_entrypoint.py b/tests/gui/test_app_entrypoint.py index 12007df..0a68bb2 100644 --- a/tests/gui/test_app_entrypoint.py +++ b/tests/gui/test_app_entrypoint.py @@ -2,21 +2,11 @@ from __future__ import annotations import importlib -import os import sys -from pathlib import Path from unittest.mock import MagicMock -import numpy as np import pytest -try: - import cv2 as cv -except Exception: - pytest.skip("OpenCV (opencv-python) is required for these tests.", allow_module_level=True) - -import dlclivegui.assets.ascii_art as ascii_mod - MODULE_UNDER_TEST = "dlclivegui.main" @@ -141,269 +131,3 @@ def test_main_without_splash(monkeypatch, set_use_splash_false): show_splash_mock.assert_not_called() assert calls["count"] == 0 win_instance.show.assert_called_once() - - -# ------------------------- -# Fixtures & small helpers -# ------------------------- - - -@pytest.fixture -def tmp_png_gray(tmp_path: Path): - """Create a simple 16x8 gray gradient PNG without alpha.""" - h, w = 8, 16 - # Horizontal gradient from black to white in BGR - x = np.linspace(0, 255, w, dtype=np.uint8) - img = np.tile(x, (h, 1)) - bgr = cv.cvtColor(img, cv.COLOR_GRAY2BGR) - p = tmp_path / "gray.png" - assert cv.imwrite(str(p), bgr) - return p - - -@pytest.fixture -def tmp_png_bgra_logo(tmp_path: Path): - """Create a small BGRA image with a transparent border and opaque center.""" - h, w = 10, 20 - bgra = np.zeros((h, w, 4), dtype=np.uint8) - # Opaque blue rectangle in center - bgra[2:8, 5:15, 0] = 255 # B - bgra[2:8, 5:15, 3] = 255 # A - p = tmp_path / "logo_bgra.png" - assert cv.imwrite(str(p), bgra) - return p - - -def _force_isatty(monkeypatch, obj, value: bool): - """ - Ensure obj.isatty() returns value. - Try instance patch first; if the object disallows attribute assignment, - patch the method on its class. - """ - try: - # Try patching the instance directly - monkeypatch.setattr(obj, "isatty", lambda: value, raising=False) - except Exception: - # Fallback: patch the class method - cls = type(obj) - monkeypatch.setattr(cls, "isatty", lambda self: value, raising=True) - - -@pytest.fixture -def force_tty(monkeypatch): - """ - Pretend stdout is a TTY and provide a stable terminal size inside the - module-under-test namespace (and the actual sys). - """ - # NO_COLOR must be unset for should_use_color("auto") - monkeypatch.delenv("NO_COLOR", raising=False) - - # Make whatever stdout object exists report TTY=True - _force_isatty(monkeypatch, sys.stdout, True) - _force_isatty(monkeypatch, ascii_mod.sys.stdout, True) - - # Ensure terminal size used by the module is deterministic - monkeypatch.setattr( - ascii_mod.shutil, - "get_terminal_size", - lambda fallback=None: os.terminal_size((80, 24)), - raising=True, - ) - return sys.stdout # not used directly, but handy - - -@pytest.fixture -def force_notty(monkeypatch): - """ - Pretend stdout is not a TTY. - """ - _force_isatty(monkeypatch, sys.stdout, False) - _force_isatty(monkeypatch, ascii_mod.sys.stdout, False) - return sys.stdout - - -# ------------------------- -# Terminal / ANSI behavior -# ------------------------- - - -def test_get_terminal_width_tty(force_tty): - width = ascii_mod.get_terminal_width(default=123) - assert width == 80 - - -def test_get_terminal_width_notty(force_notty): - width = ascii_mod.get_terminal_width(default=123) - assert width == 123 - - -def test_should_use_color_auto_tty(force_tty, monkeypatch): - monkeypatch.delenv("NO_COLOR", raising=False) - assert ascii_mod.should_use_color("auto") is True - - -def test_should_use_color_auto_no_color(force_tty, monkeypatch): - monkeypatch.setenv("NO_COLOR", "1") - assert ascii_mod.should_use_color("auto") is False - - -def test_should_use_color_modes(force_notty): - assert ascii_mod.should_use_color("never") is False - assert ascii_mod.should_use_color("always") is True - - -def test_terminal_is_wide_enough(force_tty): - assert ascii_mod.terminal_is_wide_enough(60) is True - assert ascii_mod.terminal_is_wide_enough(100) is False - - -# ------------------------- -# Image helpers -# ------------------------- - - -def test__to_bgr_converts_gray(): - gray = np.zeros((5, 7), dtype=np.uint8) - bgr = ascii_mod._to_bgr(gray) - assert bgr.shape == (5, 7, 3) - assert bgr.dtype == np.uint8 - - -def test_composite_over_color_bgra(tmp_png_bgra_logo): - img = cv.imread(str(tmp_png_bgra_logo), cv.IMREAD_UNCHANGED) - assert img.shape[2] == 4 - bgr = ascii_mod.composite_over_color(img, bg_bgr=(10, 20, 30)) - assert bgr.shape[2] == 3 - # Transparent border should become the bg color - assert tuple(bgr[0, 0]) == (10, 20, 30) - # Opaque center should keep blue channel high - assert bgr[5, 10, 0] == 255 - - -def test_crop_to_content_alpha(tmp_png_bgra_logo): - img = cv.imread(str(tmp_png_bgra_logo), cv.IMREAD_UNCHANGED) - cropped = ascii_mod.crop_to_content_alpha(img, alpha_thresh=1, pad=0) - h, w = cropped.shape[:2] - assert h == 6 # 2..7 -> 6 rows - assert w == 10 # 5..14 -> 10 cols - assert cropped[..., 3].min() == 255 - - -def test_crop_to_content_bg_white(tmp_path): - # Create white background with a black rectangle - h, w = 12, 20 - bgr = np.full((h, w, 3), 255, dtype=np.uint8) - bgr[3:10, 4:15] = 0 - p = tmp_path / "white_bg.png" - assert cv.imwrite(str(p), bgr) - cropped = ascii_mod.crop_to_content_bg(bgr, bg="white", tol=10, pad=0) - assert cropped.shape[0] == 7 # 3..9 -> 7 rows - assert cropped.shape[1] == 11 # 4..14 -> 11 cols - - -def test_resize_for_terminal_aspect_env(monkeypatch): - img = np.zeros((100, 200, 3), dtype=np.uint8) - monkeypatch.setenv("DLCLIVE_ASCII_ASPECT", "0.25") - resized = ascii_mod.resize_for_terminal(img, width=80, aspect=None) - # new_h = (h/w) * width * aspect = (100/200)*80*0.25 = 10 - assert resized.shape[:2] == (10, 80) - - -# ------------------------- -# Rendering -# ------------------------- - - -def test_map_luminance_to_chars_simple(): - gray = np.array([[0, 127, 255]], dtype=np.uint8) - lines = list(ascii_mod._map_luminance_to_chars(gray, fine=False)) - assert len(lines) == 1 - # First char should be the densest in the simple ramp '@', last should be space - assert lines[0][0] == ascii_mod.ASCII_RAMP_SIMPLE[0] - assert lines[0][-1] == ascii_mod.ASCII_RAMP_SIMPLE[-1] - - -def test_color_ascii_lines_basic(): - # Small 2x3 color blocks - img = np.zeros((2, 3, 3), dtype=np.uint8) - img[:] = (10, 20, 30) - lines = list(ascii_mod._color_ascii_lines(img, fine=False, invert=False)) - assert len(lines) == 2 - # Expect ANSI 24-bit color sequence present - assert "\x1b[38;2;" in lines[0] - # Reset code present - assert lines[0].endswith("\x1b[0m" * 3) is False # individual chars have resets, but line won't end with triple - - -# ------------------------- -# Public API: generate & print -# ------------------------- - - -@pytest.mark.parametrize("use_color", ["never", "always"]) -def test_generate_ascii_lines_gray(tmp_png_gray, use_color, force_tty): - lines = list( - ascii_mod.generate_ascii_lines( - str(tmp_png_gray), - width=40, - aspect=0.5, - color=use_color, - fine=False, - invert=False, - crop_content=False, - crop_bg="none", - ) - ) - assert len(lines) > 0 - # Width equals number of characters per line - assert all(len(line) == 40 or ("\x1b[38;2;" in line and len(_strip_ansi(line)) == 40) for line in lines) - - -def _strip_ansi(s: str) -> str: - import re - - return re.sub(r"\x1b\[[0-9;]*m", "", s) - - -def test_generate_ascii_lines_crop_alpha(tmp_png_bgra_logo, force_tty): - lines_no_crop = list( - ascii_mod.generate_ascii_lines(str(tmp_png_bgra_logo), width=40, aspect=0.5, color="never", crop_content=False) - ) - lines_crop = list( - ascii_mod.generate_ascii_lines(str(tmp_png_bgra_logo), width=40, aspect=0.5, color="never", crop_content=True) - ) - # Both are non-empty; height may change either way depending on aspect ratio - assert len(lines_no_crop) > 0 and len(lines_crop) > 0 - # Cropping should affect the generated ASCII content - assert lines_crop != lines_no_crop - - -def test_print_ascii_writes_file(tmp_png_gray, force_tty, tmp_path): - out_path = tmp_path / "out.txt" - ascii_mod.print_ascii( - str(tmp_png_gray), - width=30, - aspect=0.5, - color="never", - output=str(out_path), - ) - assert out_path.exists() - text = out_path.read_text(encoding="utf-8") - # Expect multiple lines of length 30 - lines = [ln for ln in text.splitlines() if ln] - assert len(lines) > 0 - assert all(len(ln) == 30 for ln in lines) - - -def test_build_help_description_tty(tmp_png_bgra_logo, monkeypatch, force_tty): - monkeypatch.setattr(ascii_mod, "ASCII_IMAGE_PATH", Path(tmp_png_bgra_logo)) - desc = ascii_mod.build_help_description(static_banner=None, color="always", min_width=60) - assert "DeepLabCut-Live GUI" in desc - assert "\x1b[36m" in desc # cyan wrapper now present since TTY is mocked correctly - - -def test_build_help_description_notty(tmp_png_bgra_logo, monkeypatch, force_notty): - monkeypatch.setattr(ascii_mod, "ASCII_IMAGE_PATH", Path(tmp_png_bgra_logo)) - desc = ascii_mod.build_help_description(static_banner=None, color="always", min_width=60) - # Not a TTY -> no banner, just the plain description - assert desc.strip() == "DeepLabCut-Live GUI — launch the graphical interface." diff --git a/tests/gui/test_ascii_art.py b/tests/gui/test_ascii_art.py new file mode 100644 index 0000000..50d702c --- /dev/null +++ b/tests/gui/test_ascii_art.py @@ -0,0 +1,278 @@ +import os +import sys +from pathlib import Path + +import numpy as np +import pytest + +try: + import cv2 as cv +except Exception as e: + raise ImportError("OpenCV (cv2) is required for these tests. Please install the main package dependencies.") from e + +import dlclivegui.assets.ascii_art as ascii_mod + +# ------------------------- +# Fixtures & small helpers +# ------------------------- + + +@pytest.fixture +def tmp_png_gray(tmp_path: Path): + """Create a simple 16x8 gray gradient PNG without alpha.""" + h, w = 8, 16 + # Horizontal gradient from black to white in BGR + x = np.linspace(0, 255, w, dtype=np.uint8) + img = np.tile(x, (h, 1)) + bgr = cv.cvtColor(img, cv.COLOR_GRAY2BGR) + p = tmp_path / "gray.png" + assert cv.imwrite(str(p), bgr) + return p + + +@pytest.fixture +def tmp_png_bgra_logo(tmp_path: Path): + """Create a small BGRA image with a transparent border and opaque center.""" + h, w = 10, 20 + bgra = np.zeros((h, w, 4), dtype=np.uint8) + # Opaque blue rectangle in center + bgra[2:8, 5:15, 0] = 255 # B + bgra[2:8, 5:15, 3] = 255 # A + p = tmp_path / "logo_bgra.png" + assert cv.imwrite(str(p), bgra) + return p + + +def _force_isatty(monkeypatch, obj, value: bool): + """ + Ensure obj.isatty() returns value. + Try instance patch first; if the object disallows attribute assignment, + patch the method on its class. + """ + try: + # Try patching the instance directly + monkeypatch.setattr(obj, "isatty", lambda: value, raising=False) + except Exception: + # Fallback: patch the class method + cls = type(obj) + monkeypatch.setattr(cls, "isatty", lambda self: value, raising=True) + + +@pytest.fixture +def force_tty(monkeypatch): + """ + Pretend stdout is a TTY and provide a stable terminal size inside the + module-under-test namespace (and the actual sys). + """ + # NO_COLOR must be unset for should_use_color("auto") + monkeypatch.delenv("NO_COLOR", raising=False) + + # Make whatever stdout object exists report TTY=True + _force_isatty(monkeypatch, sys.stdout, True) + _force_isatty(monkeypatch, ascii_mod.sys.stdout, True) + + # Ensure terminal size used by the module is deterministic + monkeypatch.setattr( + ascii_mod.shutil, + "get_terminal_size", + lambda fallback=None: os.terminal_size((80, 24)), + raising=True, + ) + return sys.stdout # not used directly, but handy + + +@pytest.fixture +def force_notty(monkeypatch): + """ + Pretend stdout is not a TTY. + """ + _force_isatty(monkeypatch, sys.stdout, False) + _force_isatty(monkeypatch, ascii_mod.sys.stdout, False) + return sys.stdout + + +# ------------------------- +# Terminal / ANSI behavior +# ------------------------- + + +def test_get_terminal_width_tty(force_tty): + width = ascii_mod.get_terminal_width(default=123) + assert width == 80 + + +def test_get_terminal_width_notty(force_notty): + width = ascii_mod.get_terminal_width(default=123) + assert width == 123 + + +def test_should_use_color_auto_tty(force_tty, monkeypatch): + monkeypatch.delenv("NO_COLOR", raising=False) + assert ascii_mod.should_use_color("auto") is True + + +def test_should_use_color_auto_no_color(force_tty, monkeypatch): + monkeypatch.setenv("NO_COLOR", "1") + assert ascii_mod.should_use_color("auto") is False + + +def test_should_use_color_modes(force_notty): + assert ascii_mod.should_use_color("never") is False + assert ascii_mod.should_use_color("always") is True + + +def test_terminal_is_wide_enough(force_tty): + assert ascii_mod.terminal_is_wide_enough(60) is True + assert ascii_mod.terminal_is_wide_enough(100) is False + + +# ------------------------- +# Image helpers +# ------------------------- + + +def test__to_bgr_converts_gray(): + gray = np.zeros((5, 7), dtype=np.uint8) + bgr = ascii_mod._to_bgr(gray) + assert bgr.shape == (5, 7, 3) + assert bgr.dtype == np.uint8 + + +def test_composite_over_color_bgra(tmp_png_bgra_logo): + img = cv.imread(str(tmp_png_bgra_logo), cv.IMREAD_UNCHANGED) + assert img.shape[2] == 4 + bgr = ascii_mod.composite_over_color(img, bg_bgr=(10, 20, 30)) + assert bgr.shape[2] == 3 + # Transparent border should become the bg color + assert tuple(bgr[0, 0]) == (10, 20, 30) + # Opaque center should keep blue channel high + assert bgr[5, 10, 0] == 255 + + +def test_crop_to_content_alpha(tmp_png_bgra_logo): + img = cv.imread(str(tmp_png_bgra_logo), cv.IMREAD_UNCHANGED) + cropped = ascii_mod.crop_to_content_alpha(img, alpha_thresh=1, pad=0) + h, w = cropped.shape[:2] + assert h == 6 # 2..7 -> 6 rows + assert w == 10 # 5..14 -> 10 cols + assert cropped[..., 3].min() == 255 + + +def test_crop_to_content_bg_white(tmp_path): + # Create white background with a black rectangle + h, w = 12, 20 + bgr = np.full((h, w, 3), 255, dtype=np.uint8) + bgr[3:10, 4:15] = 0 + p = tmp_path / "white_bg.png" + assert cv.imwrite(str(p), bgr) + cropped = ascii_mod.crop_to_content_bg(bgr, bg="white", tol=10, pad=0) + assert cropped.shape[0] == 7 # 3..9 -> 7 rows + assert cropped.shape[1] == 11 # 4..14 -> 11 cols + + +def test_resize_for_terminal_aspect_env(monkeypatch): + img = np.zeros((100, 200, 3), dtype=np.uint8) + monkeypatch.setenv("DLCLIVE_ASCII_ASPECT", "0.25") + resized = ascii_mod.resize_for_terminal(img, width=80, aspect=None) + # new_h = (h/w) * width * aspect = (100/200)*80*0.25 = 10 + assert resized.shape[:2] == (10, 80) + + +# ------------------------- +# Rendering +# ------------------------- + + +def test_map_luminance_to_chars_simple(): + gray = np.array([[0, 127, 255]], dtype=np.uint8) + lines = list(ascii_mod._map_luminance_to_chars(gray, fine=False)) + assert len(lines) == 1 + # First char should be the densest in the simple ramp '@', last should be space + assert lines[0][0] == ascii_mod.ASCII_RAMP_SIMPLE[0] + assert lines[0][-1] == ascii_mod.ASCII_RAMP_SIMPLE[-1] + + +def test_color_ascii_lines_basic(): + # Small 2x3 color blocks + img = np.zeros((2, 3, 3), dtype=np.uint8) + img[:] = (10, 20, 30) + lines = list(ascii_mod._color_ascii_lines(img, fine=False, invert=False)) + assert len(lines) == 2 + # Expect ANSI 24-bit color sequence present + assert "\x1b[38;2;" in lines[0] + # Reset code present + assert lines[0].endswith("\x1b[0m" * 3) is False # individual chars have resets, but line won't end with triple + + +# ------------------------- +# Public API: generate & print +# ------------------------- + + +@pytest.mark.parametrize("use_color", ["never", "always"]) +def test_generate_ascii_lines_gray(tmp_png_gray, use_color, force_tty): + lines = list( + ascii_mod.generate_ascii_lines( + str(tmp_png_gray), + width=40, + aspect=0.5, + color=use_color, + fine=False, + invert=False, + crop_content=False, + crop_bg="none", + ) + ) + assert len(lines) > 0 + # Width equals number of characters per line + assert all(len(line) == 40 or ("\x1b[38;2;" in line and len(_strip_ansi(line)) == 40) for line in lines) + + +def _strip_ansi(s: str) -> str: + import re + + return re.sub(r"\x1b\[[0-9;]*m", "", s) + + +def test_generate_ascii_lines_crop_alpha(tmp_png_bgra_logo, force_tty): + lines_no_crop = list( + ascii_mod.generate_ascii_lines(str(tmp_png_bgra_logo), width=40, aspect=0.5, color="never", crop_content=False) + ) + lines_crop = list( + ascii_mod.generate_ascii_lines(str(tmp_png_bgra_logo), width=40, aspect=0.5, color="never", crop_content=True) + ) + # Both are non-empty; height may change either way depending on aspect ratio + assert len(lines_no_crop) > 0 and len(lines_crop) > 0 + # Cropping should affect the generated ASCII content + assert lines_crop != lines_no_crop + + +def test_print_ascii_writes_file(tmp_png_gray, force_tty, tmp_path): + out_path = tmp_path / "out.txt" + ascii_mod.print_ascii( + str(tmp_png_gray), + width=30, + aspect=0.5, + color="never", + output=str(out_path), + ) + assert out_path.exists() + text = out_path.read_text(encoding="utf-8") + # Expect multiple lines of length 30 + lines = [ln for ln in text.splitlines() if ln] + assert len(lines) > 0 + assert all(len(ln) == 30 for ln in lines) + + +def test_build_help_description_tty(tmp_png_bgra_logo, monkeypatch, force_tty): + monkeypatch.setattr(ascii_mod, "ASCII_IMAGE_PATH", Path(tmp_png_bgra_logo)) + desc = ascii_mod.build_help_description(static_banner=None, color="always", min_width=60) + assert "DeepLabCut-Live GUI" in desc + assert "\x1b[36m" in desc # cyan wrapper now present since TTY is mocked correctly + + +def test_build_help_description_notty(tmp_png_bgra_logo, monkeypatch, force_notty): + monkeypatch.setattr(ascii_mod, "ASCII_IMAGE_PATH", Path(tmp_png_bgra_logo)) + desc = ascii_mod.build_help_description(static_banner=None, color="always", min_width=60) + # Not a TTY -> no banner, just the plain description + assert desc.strip() == "DeepLabCut-Live GUI — launch the graphical interface." From 09a836442768430117531309fd57af90c1d5bea1 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Thu, 19 Feb 2026 17:06:48 +0100 Subject: [PATCH 21/21] Update python-package.yml --- .github/workflows/python-package.yml | 98 +++++++++++++--------------- 1 file changed, 45 insertions(+), 53 deletions(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 3640f00..fa4a699 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -2,47 +2,26 @@ name: Build, validate & Release on: push: - tags: - - 'v*.*.*' + tags: [ 'v*.*.*' ] pull_request: - branches: - - main - - public - types: - - labeled - - opened - - edited - - synchronize - - reopened + branches: [ main, public ] + types: [ labeled, opened, edited, synchronize, reopened ] jobs: - build_check: - name: Build & validate package + test: + name: Test / smoke (matrix) runs-on: ubuntu-latest strategy: fail-fast: false matrix: - python-version: [ "3.10", "3.11", "3.12" ] # adjust to what you support + python-version: [ "3.10", "3.11", "3.12" ] steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Setup Python ${{ matrix.python-version }} - uses: actions/setup-python@v6 + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} - # If we use UV later, the lock file should be included here for caching and validation - - name: Cache pip - uses: actions/cache@v4 - with: - path: ~/.cache/pip - key: ${{ runner.os }}-pip-${{ matrix.python-version }}-${{ hashFiles('pyproject.toml', 'setup.cfg', 'setup.py', 'requirements.txt') }} - restore-keys: | - ${{ runner.os }}-pip-${{ matrix.python-version }}- - ${{ runner.os }}-pip- - - - name: Install build tools + - name: Install tools run: | python -m pip install --upgrade pip python -m pip install build twine wheel "packaging>=24.2" @@ -61,50 +40,63 @@ jobs: - name: Install from wheel & smoke test run: | - # Install from the built wheel (not from the source tree) python -m pip install dist/*.whl - python - <<'PY' import importlib - pkg_name = "dlclivegui" # change if your top-level import differs + pkg_name = "dlclivegui" m = importlib.import_module(pkg_name) print("Imported:", m.__name__, "version:", getattr(m, "__version__", "n/a")) PY if ! command -v dlclivegui >/dev/null 2>&1; then - echo "CLI entry point 'dlclivegui' not found in PATH; skipping CLI smoke test." + echo "CLI entry point 'dlclivegui' not found in PATH; skipping CLI smoke test." else + if command -v dlclivegui >/dev/null 2>&1; then echo "Running 'dlclivegui --help' smoke test..." if ! dlclivegui --help >/dev/null 2>&1; then echo "::error::'dlclivegui --help' failed; this indicates a problem with the installed CLI package." exit 1 fi - fi - publish: - name: Publish to PyPI + build: + name: Build release artifacts (single) runs-on: ubuntu-latest - needs: build_check - if: ${{ startsWith(github.ref, 'refs/tags/v') }} # only on tag pushes like v1.2.3 + needs: test + if: ${{ startsWith(github.ref, 'refs/tags/v') }} steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Setup Python - uses: actions/setup-python@v6 + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 with: - python-version: "3.x" + python-version: "3.12" - - name: Install build tools + - name: Build distributions (sdist + wheel) run: | python -m pip install --upgrade pip - python -m pip install build twine + python -m pip install build twine wheel "packaging>=24.2" + python -m build + python -m twine check dist/* - - name: Build distributions (sdist + wheel) - run: python -m build + - name: Upload dist artifacts + uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/* + if-no-files-found: error + + publish: + name: Publish to PyPI (OIDC) + runs-on: ubuntu-latest + needs: build + if: ${{ startsWith(github.ref, 'refs/tags/v') }} + environment: pypi + permissions: + id-token: write + steps: + - name: Download dist artifacts + uses: actions/download-artifact@v4 + with: + name: dist + path: dist - name: Publish to PyPI - env: - TWINE_USERNAME: __token__ - TWINE_PASSWORD: ${{ secrets.TWINE_API_KEY }} - run: python -m twine upload --verbose dist/* + uses: pypa/gh-action-pypi-publish@release/v1