-
-
Notifications
You must be signed in to change notification settings - Fork 0
Description
Unify output system — channel-owned FDs, graduated quiet axis, eliminate raw print()
Problem
The codebase has three parallel output paths that don't coordinate:
print_*()(50 calls) — user-facing[OK]/[WARN]/[SKIP]messages, gated by_should_print()at a single threshold (-2), writing to stdoutout.emit()(15 calls) — THAC0 diagnostic messages with per-level per-channel filtering, writing to stderr- Raw
print()(~106 calls) — ungated banners, summaries, config display, error guidance, badge URLs, next-steps — writing to stdout with zero THAC0 awareness
This creates three concrete problems:
The -QQ dead zone. Every -Q step should suppress something new, but -QQ is identical to -Q:
-Q hides hints (level 0) ← meaningful
-QQ hides... nothing new ← dead zone
-QQQ hides all print_*() at once ← too coarse
-QQQQ hard wall ← meaningful
No graduated suppression. _should_print() treats all print_*() identically — an [OK] per-file confirmation has the same priority as a [WARN] about missing gist scope. They all vanish together at -QQQ.
106 ungated print() calls. The largest output category has zero THAC0 awareness. Banners, summaries, config display, PAT guidance, badge URLs — none of them can be filtered. This is ~67% of all user-facing output.
Proposed solution
Unify all output through emit() by giving channels their own output destination (FD). The print_*() functions become thin wrappers around emit() with semantic defaults:
# output.py — print_*() becomes a convenience wrapper
def print_ok(msg, level=MINIMAL, channel='general'):
get_output().emit(level, f" [OK] {msg}", channel=channel)
def print_warn(msg, level=WARNING, channel='general'):
get_output().emit(level, f" [WARN] {msg}", channel=channel)
def print_info(msg, level=MINIMAL, channel='general'):
get_output().emit(level, f" {msg}", channel=channel)The FD is a property of the channel, not the message. Commands configure channel FDs for their context:
# create.py — setup is user-facing for this command
def run(args):
out = get_output()
out.set_channel_fd('setup', sys.stdout)
# verify.py — setup is diagnostic background checks
def run(args):
out = get_output()
# setup stays on stderr (default)Channel-owned output destinations
Add an fd field to channel configuration so emit() routes output to the right destination:
| Channel | Default FD | Rationale |
|---|---|---|
general |
stdout | User-facing command output |
error |
stderr | Errors always to stderr |
hint |
stdout | User-facing contextual tips |
setup |
stderr | Diagnostic by default (commands override for their context) |
api |
stderr | Diagnostic trace |
gist |
stderr | Diagnostic trace |
config |
stderr | Diagnostic trace |
trace |
stderr | Debug-level function tracing |
The emit() method looks up the channel's FD instead of always using self.file:
def emit(self, level, message, *, channel='general', **kwargs):
threshold = self.channel_overrides.get(channel, self.verbosity)
if threshold <= -4:
return
if level > threshold:
return
text = message.format(**kwargs) if kwargs else message
fd = self.channel_fds.get(channel, self.file)
print(text, file=fd)Graduated quiet axis
Each print_*() function gets a semantic default level:
| Function | Default level | Constant |
|---|---|---|
print_error(msg) |
-3 | ERROR |
print_warn(msg) |
-2 | WARNING |
print_ok(msg) |
-1 | MINIMAL |
print_skip(msg) |
-1 | MINIMAL |
print_dry(msg) |
-1 | MINIMAL |
print_step(n, total, msg) |
-1 | MINIMAL |
print_info(msg) |
-1 | MINIMAL |
print_banner(title) |
-1 | MINIMAL |
The quiet axis becomes graduated with no dead zones:
| Verbosity | Hints (0) | [OK]/[SKIP]/info (-1) | [WARN] (-2) | ERROR (-3) |
|---|---|---|---|---|
| 0 (default) | shown | shown | shown | shown |
| -1 (-Q) | hidden | shown | shown | shown |
| -2 (-QQ) | hidden | hidden | shown | shown |
| -3 (-QQQ) | hidden | hidden | hidden | shown |
| -4 (-QQQQ) | hidden | hidden | hidden | hidden |
Callers can override the level when context demands it:
print_ok(f"{rel_path}", level=0) # per-file noise — hidden at -Q
print_ok("Setup complete!") # summary — stays at default -1Raw print() migration
New convenience functions cover the remaining categories:
| Current raw print() | Migrated to |
|---|---|
print("ghtraf init — Copy template files") |
print_banner("ghtraf init — Copy template files") |
print("=" * 40) |
Absorbed into print_banner() |
print(f" Owner: {owner}") |
print_info(f"Owner: {owner}") |
print(f" Copied {n} file(s)") |
print_info(f"Copied {n} file(s)") |
print("ERROR: --owner is required") |
print_error("--owner is required") |
print(f" Installs: {url}") |
print_info(f"Installs: {url}") |
print(" Run: gh auth login") |
print_info("Run: gh auth login") |
print() (empty lines) |
print_info("") (filterable spacing) |
print(" Please enter y, n, or a.") |
Leave as-is (interactive prompt) |
Implementation approach
Phase 1 — log_lib changes (generic, benefits PSS too):
- Add
fdfield toChannelConfig(defaultNone→ falls back to manager'sself.file) - Add
channel_fdsdict toOutputManager.__init__(), populated from channel configs - Add
set_channel_fd(channel, fd)method for per-command overrides - Update
emit()to usechannel_fds.get(channel, self.file)for output routing - Update
hint()similarly (hint channel already exists)
Phase 2 — output.py rewrite:
- Rewrite all print_*() to route through
emit()with(msg, level, channel)signature - Add
print_info()andprint_banner()for raw print() replacement - Remove
_should_print()(dead code — emit() handles gating) - Keep
prompt()ungated (interactive, not filterable)
Phase 3 — Caller migration:
commands/init.py— 13 raw print() → print_info/print_bannercommands/create.py— 62 raw print() → print_info/print_banner/print_error + set_channel_fd for setupconfigure.py— 3 raw print() → print_infogist.py— 5 raw print() → print_info/print_okgh.py— 13 raw print() → print_error/print_info
Phase 4 — Test updates:
- Update
test_output.py— new quiet axis expectations, test level/channel params - Update capsys assertions in
test_init.py,test_cli.pyas needed - Add tests for channel FD routing
Design considerations
- stdout/stderr split is preserved — user-facing channels default to stdout, diagnostic channels to stderr. This maintains piping compatibility (
ghtraf create ... | grep badge). - log_lib stays generic —
ChannelConfig.fd,channel_fds, andset_channel_fd()are project-agnostic. PSS could use the same mechanism (algorithm results → stdout, timing → stderr). - Backward compatibility — callers that don't pass
level=orchannel=get the same defaults as today. At default verbosity (0), output is identical. would_emit()is unnecessary — if channels own their FD, print_*() routes through emit() directly. No separate threshold-check workaround needed.- FD3/FD4 future — channel-owned FDs naturally extend to machine-readable output (e.g.,
--jsonon FD3) without redesign. - Per-command channel FD overrides — a channel's importance depends on context.
setupis user-facing increatebut diagnostic inverify. Commands configure this at startup, not per-message. prompt()stays ungated — interactive input cannot be filtered by verbosity. Prompt text is not channeled.- Pre-init safety — print_*() wraps emit() in try/except, falling back to raw print() if OutputManager isn't initialized yet.
Acceptance criteria
-
ChannelConfighas anfdfield for output destination -
OutputManagerhaschannel_fdsdict andset_channel_fd()method -
emit()routes output to the channel's configured FD (falls back toself.file) - GTT channels.py configures
generalandhintto stdout, rest to stderr - All print_*() functions accept optional
levelandchannelparameters -
print_ok/skip/dry/step/info/bannerdefault to level -1 (MINIMAL) -
print_warndefaults to level -2 (WARNING) -
print_errordefaults to level -3 (ERROR) via stderr - New
print_info()covers neutral informational output (no prefix) - New
print_banner()covers command headers with separator lines -
_should_print()is removed from output.py - Zero raw
print()calls remain in src/ghtraf/ (excludingprompt()and interactive-only paths) -
-Qsuppresses hints only (level 0) -
-QQsuppresses[OK],[SKIP],[STEP],[DRY RUN],print_info,print_banner(level -1) -
-QQQsuppresses[WARN](level -2), errors still visible -
-QQQQsuppresses everything (hard wall) -
prompt()is never filtered by verbosity - All existing tests pass with updated assertions
- At least one command uses
set_channel_fd()to override channel output for its context
Related issues
- Supersedes Bridge output.py with OutputManager — GTT channels and hints #16 — Bridge output.py with OutputManager — GTT channels and hints #16 added
_should_print()as a bridge; this replaces it with proper per-level gating through emit() - Extends Instrument modules with emit() — diagnostic output for gh/config/gist #17 — Instrument modules with emit() — diagnostic output for gh/config/gist #17 planned emit() instrumentation for diagnostic output; this adds the user-facing half (print_*() migration + raw print() elimination)
- Refs THAC0 verbosity system with named channels #13 — THAC0 epic (this completes the output unification that Block 1 and Block 3 started)
- Refs ghtraf — PyPI-distributed CLI tool for managing traffic tracking #6 — CLI epic (unified output affects all current and future subcommands)
Analysis
See 2026-02-28__23-41-40__dev-workflow-process_unify-print-emit-output-system.md for the full analysis including Solutions A-D evaluation, the stdout/stderr insight, and two addenda refining the channel-owned FD design.