diff --git a/README.md b/README.md
index a2646bde..64d2cd7e 100644
--- a/README.md
+++ b/README.md
@@ -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.
+
If Semantica solves a real problem for you, a star helps others find it.
diff --git a/docs/reference/utils.md b/docs/reference/utils.md
index 83f9cc97..f7e97789 100644
--- a/docs/reference/utils.md
+++ b/docs/reference/utils.md
@@ -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
```
+
+
+ **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.
+
diff --git a/semantica/utils/progress_tracker.py b/semantica/utils/progress_tracker.py
index febfb4b9..f27768d4 100644
--- a/semantica/utils/progress_tracker.py
+++ b/semantica/utils/progress_tracker.py
@@ -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
diff --git a/tests/test_progress_tracker_regressions.py b/tests/test_progress_tracker_regressions.py
index ac4c09da..b42a885b 100644
--- a/tests/test_progress_tracker_regressions.py
+++ b/tests/test_progress_tracker_regressions.py
@@ -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()}"