Skip to content

Unify output system — channel-owned FDs, graduated quiet axis, eliminate raw print() #59

@djdarcy

Description

@djdarcy

Unify output system — channel-owned FDs, graduated quiet axis, eliminate raw print()

Problem

The codebase has three parallel output paths that don't coordinate:

  1. print_*() (50 calls) — user-facing [OK]/[WARN]/[SKIP] messages, gated by _should_print() at a single threshold (-2), writing to stdout
  2. out.emit() (15 calls) — THAC0 diagnostic messages with per-level per-channel filtering, writing to stderr
  3. 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 -1

Raw 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 fd field to ChannelConfig (default None → falls back to manager's self.file)
  • Add channel_fds dict to OutputManager.__init__(), populated from channel configs
  • Add set_channel_fd(channel, fd) method for per-command overrides
  • Update emit() to use channel_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() and print_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_banner
  • commands/create.py — 62 raw print() → print_info/print_banner/print_error + set_channel_fd for setup
  • configure.py — 3 raw print() → print_info
  • gist.py — 5 raw print() → print_info/print_ok
  • gh.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.py as 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 genericChannelConfig.fd, channel_fds, and set_channel_fd() are project-agnostic. PSS could use the same mechanism (algorithm results → stdout, timing → stderr).
  • Backward compatibility — callers that don't pass level= or channel= 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., --json on FD3) without redesign.
  • Per-command channel FD overrides — a channel's importance depends on context. setup is user-facing in create but diagnostic in verify. 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

  • ChannelConfig has an fd field for output destination
  • OutputManager has channel_fds dict and set_channel_fd() method
  • emit() routes output to the channel's configured FD (falls back to self.file)
  • GTT channels.py configures general and hint to stdout, rest to stderr
  • All print_*() functions accept optional level and channel parameters
  • print_ok/skip/dry/step/info/banner default to level -1 (MINIMAL)
  • print_warn defaults to level -2 (WARNING)
  • print_error defaults 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/ (excluding prompt() and interactive-only paths)
  • -Q suppresses hints only (level 0)
  • -QQ suppresses [OK], [SKIP], [STEP], [DRY RUN], print_info, print_banner (level -1)
  • -QQQ suppresses [WARN] (level -2), errors still visible
  • -QQQQ suppresses 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

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    architectureStructural and architectural decisionsclightraf CLI tool and command-line interfacerefactorCode restructuring without behavior change

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions