From 6a4f0d3eae338744cacb8fe1afd8dcc8fec14388 Mon Sep 17 00:00:00 2001 From: Hagen Wierstorf Date: Mon, 20 Jul 2026 08:55:59 +0200 Subject: [PATCH 1/2] Disable shimmer animation for NO_COLOR + TERM=dumb --- audb/core/shimmer.py | 47 +++++++++++++++++++++++++++- tests/test_shimmer.py | 73 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+), 1 deletion(-) diff --git a/audb/core/shimmer.py b/audb/core/shimmer.py index 6166648c..ba37755a 100644 --- a/audb/core/shimmer.py +++ b/audb/core/shimmer.py @@ -4,6 +4,7 @@ import contextlib import math +import os import shutil import sys import threading @@ -26,6 +27,33 @@ _active_shimmer: Shimmer | None = None +def animations_enabled() -> bool: + r"""If the environment allows terminal animations. + + Terminal animations rely on ANSI escape sequences, + which end up as garbage in log files + when the output is captured, + e.g. by ``screen -L`` or ``script``. + Since a capturing pseudo-terminal is indistinguishable + from an interactive one, + users can disable animations explicitly + through their environment. + Animations are disabled if + + * ``NO_COLOR`` is set to a non-empty value + * ``TERM`` is set to ``dumb`` + + Returns: + ``True`` if terminal animations are allowed + + """ + if os.environ.get("NO_COLOR", ""): + return False + if os.environ.get("TERM", "") == "dumb": + return False + return True + + class Shimmer: """Animate text with a bright shimmer sweeping across. @@ -80,14 +108,25 @@ def start(self): * ``sys.stdout`` is not a TTY (e.g. redirected output, Jupyter, CI logs). + * The environment disables animations + via ``NO_COLOR`` or ``TERM=dumb``, + see :func:`animations_enabled`. * Another ``Shimmer`` instance is already active (only one may run at a time). + In all those cases the static text + is still printed once. + """ global _active_shimmer # Skip animation in non-interactive environments - if not hasattr(sys.stdout, "isatty") or not sys.stdout.isatty(): + # and when disabled via NO_COLOR or TERM=dumb + if ( + not hasattr(sys.stdout, "isatty") + or not sys.stdout.isatty() + or not animations_enabled() + ): sys.stdout.write(f"{self._prefix}{self._text}{self._suffix}\n") sys.stdout.flush() self._noop = True @@ -286,6 +325,12 @@ def shimmer( ``start()`` / ``try`` / ``finally`` / ``stop()`` dance. The animation is always stopped on exit, including when the block raises. + Independent of ``enabled``, + the animation is skipped + (but the static text still printed) + in non-interactive environments + and when disabled via ``NO_COLOR`` or ``TERM=dumb``, + see :meth:`Shimmer.start`. Args: prefix: static text before the animated portion diff --git a/tests/test_shimmer.py b/tests/test_shimmer.py index c25215b6..d8e516a8 100644 --- a/tests/test_shimmer.py +++ b/tests/test_shimmer.py @@ -3,6 +3,7 @@ import sys import numpy as np +import pytest import audeer import audformat @@ -14,6 +15,22 @@ from audb.core.shimmer import NORMAL from audb.core.shimmer import RESET from audb.core.shimmer import Shimmer +from audb.core.shimmer import animations_enabled + + +@pytest.fixture(autouse=True) +def enable_animations(monkeypatch): + r"""Neutralize animation-disabling environment variables. + + The tests below control animation behavior explicitly + (by monkey-patching ``isatty`` or setting environment variables), + so the environment the test runner happens to execute in + (e.g. CI with ``NO_COLOR`` or ``TERM=dumb``) + must not interfere. + + """ + monkeypatch.delenv("NO_COLOR", raising=False) + monkeypatch.setenv("TERM", "xterm-256color") def test_stdout_write_hook(): @@ -127,6 +144,62 @@ def test_noop_when_not_a_tty(monkeypatch): shimmer.stop() +def test_animations_enabled(monkeypatch): + """Environment variables controlling terminal animations.""" + # Clean environment (see enable_animations fixture): allowed + assert animations_enabled() is True + + # NO_COLOR set to an empty string counts as unset, + # following the https://no-color.org convention + monkeypatch.setenv("NO_COLOR", "") + assert animations_enabled() is True + + # NO_COLOR set to any non-empty value disables animations + monkeypatch.setenv("NO_COLOR", "1") + assert animations_enabled() is False + + # TERM=dumb disables animations + monkeypatch.delenv("NO_COLOR") + monkeypatch.setenv("TERM", "dumb") + assert animations_enabled() is False + + # Other TERM values keep animations enabled + monkeypatch.setenv("TERM", "screen-256color") + assert animations_enabled() is True + + +@pytest.mark.parametrize( + "env_var, value", + [ + ("NO_COLOR", "1"), + ("TERM", "dumb"), + ], +) +def test_noop_when_animations_disabled(capsys, monkeypatch, env_var, value): + """Shimmer becomes a no-op when the environment disables animations. + + Even on a real TTY (e.g. a pseudo-terminal created by ``screen -L``, + whose log file would otherwise capture the escape sequences), + setting ``NO_COLOR`` or ``TERM=dumb`` must skip the animation, + while the static text is still printed once. + + """ + monkeypatch.setattr(sys.stdout, "isatty", lambda: True) + monkeypatch.setenv(env_var, value) + original_write = sys.stdout.write + + shimmer = Shimmer("Get: ", "db v1.0.0") + shimmer.start() + # Should not have patched stdout: no instance-level override installed. + assert "write" not in vars(sys.stdout) + assert sys.stdout.write == original_write + assert shimmer._noop is True + shimmer.stop() + + # The static line is printed exactly once, without escape codes + assert capsys.readouterr().out == "Get: db v1.0.0\n" + + def test_restores_pre_existing_instance_write(monkeypatch): """A pre-existing instance-level ``write`` is restored, not deleted. From 2592511aa3b2f85114f54976415c3f64e2f92ee3 Mon Sep 17 00:00:00 2001 From: Hagen Wierstorf Date: Mon, 20 Jul 2026 09:07:06 +0200 Subject: [PATCH 2/2] Case-insensitive TERM, precedence test --- audb/core/shimmer.py | 2 +- tests/test_shimmer.py | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/audb/core/shimmer.py b/audb/core/shimmer.py index ba37755a..8f4ea248 100644 --- a/audb/core/shimmer.py +++ b/audb/core/shimmer.py @@ -49,7 +49,7 @@ def animations_enabled() -> bool: """ if os.environ.get("NO_COLOR", ""): return False - if os.environ.get("TERM", "") == "dumb": + if os.environ.get("TERM", "").lower() == "dumb": return False return True diff --git a/tests/test_shimmer.py b/tests/test_shimmer.py index d8e516a8..65aa87fa 100644 --- a/tests/test_shimmer.py +++ b/tests/test_shimmer.py @@ -163,10 +163,20 @@ def test_animations_enabled(monkeypatch): monkeypatch.setenv("TERM", "dumb") assert animations_enabled() is False + # TERM matching is case-insensitive + monkeypatch.setenv("TERM", "DUMB") + assert animations_enabled() is False + # Other TERM values keep animations enabled monkeypatch.setenv("TERM", "screen-256color") assert animations_enabled() is True + # Either variable alone is enough to disable, + # and both set together stays disabled + monkeypatch.setenv("NO_COLOR", "1") + monkeypatch.setenv("TERM", "dumb") + assert animations_enabled() is False + @pytest.mark.parametrize( "env_var, value",