mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-08-29 04:26:20 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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()}"
|
||||
|
||||
Reference in New Issue
Block a user