fix(utils): write console progress only to an interactive stdout

ProgressTracker attached ConsoleProgressDisplay unconditionally, so any
script or CI job that piped or redirected stdout had one progress bar per
stage written into its output, escape sequences included. A plain
`python demo.py > out.txt` captured 173 bytes of progress-bar noise around
10 bytes of the program's own output.

Console progress is now attached only when stdout is an interactive
terminal, when running under Jupyter, or when SEMANTICA_FORCE_PROGRESS is
set. FileProgressDisplay is untouched, so progress logging still works in
pipelines, and SEMANTICA_DISABLE_PROGRESS keeps its existing meaning and
still takes precedence.

Both progress environment variables are now documented in the README and
the utils reference; SEMANTICA_DISABLE_PROGRESS previously existed only in
the reference page.

Deviations from the issue: the issue suggested disabling the tracker on
non-TTY stdout. This gates the display instead, because disabling the
tracker would short-circuit before FileProgressDisplay and take file
progress logging down with it, and the ~20 modules that set
`progress_tracker.enabled = True` in __init__ would need the property
setter taught about TTY state to avoid undoing it. Gating the display
leaves both alone.

Design note: the claim comment on the issue proposed an
`enabled: Optional[bool] = None` constructor opt-in; during implementation
the opt-in became SEMANTICA_FORCE_PROGRESS, which needs no signature change
and follows the NO_COLOR/FORCE_COLOR convention. Known limitation: TTY
detection runs once at tracker construction (the tracker is a process-wide
singleton), so a process that redirects stdout after first use needs the
env vars to change behaviour.

Fixes #1185
This commit is contained in:
Aldrin Joseph
2026-08-23 22:17:31 +05:30
parent 6c2ccfd3af
commit de31b43663
4 changed files with 142 additions and 5 deletions
+2
View File
@@ -147,6 +147,8 @@ semantica doctor
# Config file pass ~/.semantica/config.yaml
```
**Running in a script or CI?** Progress bars are written only when stdout is an interactive terminal (or a Jupyter notebook), so piping and redirecting stay clean by default. Override with `SEMANTICA_DISABLE_PROGRESS=1` to silence progress everywhere, or `SEMANTICA_FORCE_PROGRESS=1` to keep it when stdout is redirected. `SEMANTICA_DISABLE_PROGRESS` takes precedence.
<div align="center">
If Semantica solves a real problem for you, a star helps others find it.
+10
View File
@@ -77,7 +77,17 @@ Most users won't call utils directly: it's the **shared foundation** for all mod
export SEMANTICA_LOG_LEVEL=DEBUG
export SEMANTICA_LOG_FORMAT=json # "json" | "text"
export SEMANTICA_DISABLE_PROGRESS=true
export SEMANTICA_FORCE_PROGRESS=true
```
<Tip>
**Progress bars follow your terminal.** Console progress is written only when
stdout is an interactive terminal (or a Jupyter notebook), so piping or
redirecting output no longer fills logs with progress bars and escape
sequences. Set `SEMANTICA_DISABLE_PROGRESS` to silence progress even in a
terminal, or `SEMANTICA_FORCE_PROGRESS` to keep it when stdout is redirected.
`SEMANTICA_DISABLE_PROGRESS` wins if both are set.
</Tip>
</Step>
</Steps>
+34 -5
View File
@@ -64,6 +64,29 @@ def _progress_disabled_from_env() -> bool:
"on",
)
def _progress_forced_from_env() -> bool:
"""Return whether console progress is forced on despite a non-interactive stdout."""
return os.getenv("SEMANTICA_FORCE_PROGRESS", "").strip().lower() in (
"1",
"true",
"yes",
"on",
)
def _stdout_is_tty() -> bool:
"""Return whether stdout is an interactive terminal.
Replacement streams do not always implement ``isatty`` and closed streams can
raise, so both cases are treated as non-interactive.
"""
try:
return bool(sys.stdout is not None and sys.stdout.isatty())
except (AttributeError, ValueError):
return False
# Try to import IPython for Jupyter support
try:
from IPython import get_ipython
@@ -1040,18 +1063,24 @@ class ProgressTracker:
# Create displays
self.displays: List[ProgressDisplay] = []
# Console output only suits an interactive stdout. When output is piped or
# redirected (scripts, CI logs) the progress bars and their escape
# sequences would otherwise drown the program's own output.
console_ok = _stdout_is_tty() or self.is_jupyter or _progress_forced_from_env()
# Always try Jupyter first if available, fallback to console
if IPYTHON_AVAILABLE:
# Try to detect Jupyter - if available, use it
if self.is_jupyter and not self.disable_jupyter_progress:
self.displays.append(JupyterProgressDisplay(use_emoji=use_emoji))
# Also add console as fallback for immediate feedback
self.displays.append(
ConsoleProgressDisplay(
use_emoji=use_emoji, update_interval=update_interval
if console_ok:
self.displays.append(
ConsoleProgressDisplay(
use_emoji=use_emoji, update_interval=update_interval
)
)
)
else:
elif console_ok:
self.displays.append(
ConsoleProgressDisplay(
use_emoji=use_emoji, update_interval=update_interval
@@ -12,6 +12,7 @@ import semantica.utils.progress_tracker as progress_module
@pytest.fixture(autouse=True)
def reset_progress_singletons(monkeypatch):
monkeypatch.delenv("SEMANTICA_DISABLE_PROGRESS", raising=False)
monkeypatch.delenv("SEMANTICA_FORCE_PROGRESS", raising=False)
progress_module.ProgressTracker._instance = None
progress_module._global_tracker = None
yield
@@ -19,6 +20,40 @@ def reset_progress_singletons(monkeypatch):
progress_module._global_tracker = None
class _FakeStdout:
"""Minimal stdout stand-in with controllable TTY reporting."""
encoding = "utf-8"
def __init__(self, tty):
self._tty = tty
self.written = []
def isatty(self):
return self._tty
def write(self, text):
self.written.append(text)
return len(text)
def flush(self):
pass
def _use_stdout(monkeypatch, tty):
"""Point sys.stdout at a fake with the given TTY behaviour, outside Jupyter."""
stream = _FakeStdout(tty=tty)
monkeypatch.setattr(sys, "stdout", stream)
monkeypatch.setattr(
progress_module.ProgressTracker, "_detect_jupyter", lambda *_: False
)
return stream
def _displays_of(tracker, display_cls):
return [d for d in tracker.displays if isinstance(d, display_cls)]
def _install_tracker_as_singleton(tracker: progress_module.ProgressTracker) -> None:
progress_module.ProgressTracker._instance = tracker
progress_module._global_tracker = tracker
@@ -100,6 +135,67 @@ def test_disable_progress_env_prevents_reenable(monkeypatch):
assert tracker.start_tracking(module="core", submodule="test") == ""
def test_console_display_omitted_when_stdout_is_not_a_tty(monkeypatch):
_use_stdout(monkeypatch, tty=False)
tracker = progress_module.ProgressTracker(use_emoji=False, update_interval=0)
assert _displays_of(tracker, progress_module.ConsoleProgressDisplay) == []
def test_console_display_present_when_stdout_is_a_tty(monkeypatch):
_use_stdout(monkeypatch, tty=True)
tracker = progress_module.ProgressTracker(use_emoji=False, update_interval=0)
assert _displays_of(tracker, progress_module.ConsoleProgressDisplay)
def test_file_display_survives_non_tty_stdout(monkeypatch):
_use_stdout(monkeypatch, tty=False)
tracker = progress_module.ProgressTracker(use_emoji=False, update_interval=0)
assert _displays_of(tracker, progress_module.FileProgressDisplay)
def test_force_progress_env_restores_console_display_on_non_tty(monkeypatch):
monkeypatch.setenv("SEMANTICA_FORCE_PROGRESS", "1")
_use_stdout(monkeypatch, tty=False)
tracker = progress_module.ProgressTracker(use_emoji=False, update_interval=0)
assert _displays_of(tracker, progress_module.ConsoleProgressDisplay)
def test_disable_progress_env_beats_force_progress_env(monkeypatch):
monkeypatch.setenv("SEMANTICA_DISABLE_PROGRESS", "1")
monkeypatch.setenv("SEMANTICA_FORCE_PROGRESS", "1")
stream = _use_stdout(monkeypatch, tty=False)
tracker = progress_module.ProgressTracker(use_emoji=False, update_interval=0)
_install_tracker_as_singleton(tracker)
assert tracker.enabled is False
assert tracker.start_tracking(module="core", submodule="test") == ""
assert stream.written == []
def test_non_tty_stdout_stays_silent_after_module_reenables_tracker(monkeypatch):
stream = _use_stdout(monkeypatch, tty=False)
tracker = progress_module.ProgressTracker(use_emoji=False, update_interval=0)
_install_tracker_as_singleton(tracker)
# Mirrors the ~20 modules that do `self.progress_tracker.enabled = True`.
tracker.enabled = True
tracking_id = tracker.start_tracking(
module="core", submodule="Semantica", message="Building"
)
tracker.update_progress(tracking_id, processed=1, total=1, message="Processing")
assert stream.written == []
def test_build_knowledge_base_subprocess_does_not_deadlock():
root = Path(__file__).resolve().parents[1]
runtime_dir = root / "test_data" / "runtime" / f"build-regression-{os.getpid()}"