Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
feat(output[emit_object]): add OutputFormatter.emit_object and Colors…
….format_rule

why: ls and debug-info bypassed OutputFormatter with raw sys.stdout.write,
breaking the 2-channel output architecture for machine-readable output.
what:
- Add OutputFormatter.emit_object() for single top-level JSON objects
- Route ls --json/--ndjson and debug-info --json through emit_object()
- Add Colors.format_rule() for Unicode box-drawing horizontal rules
- Add unit tests for emit_object in JSON, NDJSON, and HUMAN modes
  • Loading branch information
tony committed Mar 8, 2026
commit ad99470b2b47b2a48820946bfcb3adb693f8ac5c
30 changes: 30 additions & 0 deletions src/tmuxp/_internal/colors.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,14 @@
from __future__ import annotations

import enum
import logging
import os
import re
import sys
import typing as t

logger = logging.getLogger(__name__)

if t.TYPE_CHECKING:
from typing import TypeAlias

Expand Down Expand Up @@ -470,6 +473,33 @@ def format_separator(self, length: int = 25) -> str:
"""
return self.muted("-" * length)

def format_rule(self, width: int = 40, char: str = "─") -> str:
"""Format a horizontal rule using Unicode box-drawing characters.

A richer alternative to ``format_separator()`` which uses plain hyphens.

Parameters
----------
width : int
Number of characters. Default is 40.
char : str
Character to repeat. Default is ``"─"`` (U+2500).

Returns
-------
str
Muted (blue) rule when colors enabled, plain rule otherwise.

Examples
--------
>>> colors = Colors(ColorMode.NEVER)
>>> colors.format_rule(10)
'──────────'
>>> colors.format_rule(5, char="=")
'====='
"""
return self.muted(char * width)

def format_kv(self, key: str, value: str) -> str:
"""Format key: value pair with syntax highlighting.

Expand Down
46 changes: 46 additions & 0 deletions src/tmuxp/cli/_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,12 @@

import enum
import json
import logging
import sys
import typing as t

logger = logging.getLogger(__name__)


class OutputMode(enum.Enum):
"""Output format modes for CLI commands.
Expand Down Expand Up @@ -117,6 +120,49 @@ def emit_text(self, text: str) -> None:
sys.stdout.write(text + "\n")
sys.stdout.flush()

def emit_object(self, data: dict[str, t.Any]) -> None:
"""Emit a single top-level JSON object (not a list of records).

For commands that produce one structured object rather than a stream of
records. Writes immediately without buffering; does not affect
``_json_buffer``.

In JSON mode, writes indented JSON followed by a newline.
In NDJSON mode, writes compact single-line JSON followed by a newline.
In HUMAN mode, does nothing (use ``emit_text`` for human output).

Parameters
----------
data : dict
The object to emit.

Examples
--------
>>> import io, sys
>>> formatter = OutputFormatter(OutputMode.JSON)
>>> formatter.emit_object({"status": "ok", "count": 3})
{
"status": "ok",
"count": 3
}
>>> formatter._json_buffer # buffer is unaffected
[]

>>> formatter2 = OutputFormatter(OutputMode.NDJSON)
>>> formatter2.emit_object({"status": "ok", "count": 3})
{"status": "ok", "count": 3}

>>> formatter3 = OutputFormatter(OutputMode.HUMAN)
>>> formatter3.emit_object({"status": "ok"}) # no output in HUMAN mode
"""
if self.mode == OutputMode.JSON:
sys.stdout.write(json.dumps(data, indent=2) + "\n")
sys.stdout.flush()
elif self.mode == OutputMode.NDJSON:
sys.stdout.write(json.dumps(data) + "\n")
sys.stdout.flush()
# HUMAN: no-op

def finalize(self) -> None:
"""Finalize output (flush JSON buffer if needed).

Expand Down
10 changes: 5 additions & 5 deletions src/tmuxp/cli/debug_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import argparse
import logging
import os
import pathlib
import platform
Expand All @@ -17,8 +18,11 @@
from tmuxp._internal.private_path import PrivatePath, collapse_home_in_string

from ._colors import Colors, build_description, get_color_mode
from ._output import OutputFormatter, OutputMode
from .utils import tmuxp_echo

logger = logging.getLogger(__name__)

DEBUG_INFO_DESCRIPTION = build_description(
"""
Print diagnostic information for debugging and issue reports.
Expand Down Expand Up @@ -243,9 +247,6 @@ def command_debug_info(
parser: argparse.ArgumentParser | None = None,
) -> None:
"""Entrypoint for ``tmuxp debug-info`` to print debug info to submit with issues."""
import json
import sys

# Get output mode
output_json = args.output_json if args else False

Expand All @@ -259,7 +260,6 @@ def command_debug_info(
# Output based on mode
if output_json:
# Single object, not wrapped in array
sys.stdout.write(json.dumps(data, indent=2) + "\n")
sys.stdout.flush()
OutputFormatter(OutputMode.JSON).emit_object(data)
else:
tmuxp_echo(_format_human_output(data, colors))
12 changes: 5 additions & 7 deletions src/tmuxp/cli/ls.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
import argparse
import datetime
import json
import logging
import pathlib
import typing as t

Expand All @@ -46,6 +47,8 @@
from ._colors import Colors, build_description, get_color_mode
from ._output import OutputFormatter, OutputMode, get_output_mode

logger = logging.getLogger(__name__)

LS_DESCRIPTION = build_description(
"""
List workspace files in the tmuxp configuration directory.
Expand Down Expand Up @@ -567,9 +570,6 @@ def command_ls(
--------
>>> # command_ls() lists workspaces from cwd/parents and ~/.tmuxp/
"""
import json
import sys

# Get color mode from args or default to AUTO
color_mode = get_color_mode(args.color if args else None)
colors = Colors(color_mode)
Expand Down Expand Up @@ -612,8 +612,7 @@ def command_ls(
"workspaces": [],
"global_workspace_dirs": global_dir_candidates,
}
sys.stdout.write(json.dumps(output_data, indent=2) + "\n")
sys.stdout.flush()
formatter.emit_object(output_data)
# NDJSON: just output nothing for empty workspaces
return

Expand All @@ -623,8 +622,7 @@ def command_ls(
"workspaces": workspaces,
"global_workspace_dirs": global_dir_candidates,
}
sys.stdout.write(json.dumps(output_data, indent=2) + "\n")
sys.stdout.flush()
formatter.emit_object(output_data)
return

# Human and NDJSON output
Expand Down
48 changes: 48 additions & 0 deletions tests/cli/test_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,54 @@ def test_ndjson_workflow(capsys: pytest.CaptureFixture[str]) -> None:
assert captured.out == ""


def test_emit_object_json_writes_immediately(
capsys: pytest.CaptureFixture[str],
) -> None:
"""JSON mode emit_object should write indented JSON immediately."""
formatter = OutputFormatter(OutputMode.JSON)
formatter.emit_object({"status": "ok", "count": 3})

captured = capsys.readouterr()
data = json.loads(captured.out)
assert data == {"status": "ok", "count": 3}
# Indented output (indent=2)
assert "\n" in captured.out


def test_emit_object_ndjson_writes_compact(
capsys: pytest.CaptureFixture[str],
) -> None:
"""NDJSON mode emit_object should write compact single-line JSON."""
formatter = OutputFormatter(OutputMode.NDJSON)
formatter.emit_object({"status": "ok", "count": 3})

captured = capsys.readouterr()
lines = captured.out.strip().split("\n")
assert len(lines) == 1
assert json.loads(lines[0]) == {"status": "ok", "count": 3}


def test_emit_object_human_silent(capsys: pytest.CaptureFixture[str]) -> None:
"""HUMAN mode emit_object should produce no output."""
formatter = OutputFormatter(OutputMode.HUMAN)
formatter.emit_object({"status": "ok"})

captured = capsys.readouterr()
assert captured.out == ""


def test_emit_object_does_not_buffer() -> None:
"""emit_object must not affect _json_buffer."""
formatter = OutputFormatter(OutputMode.JSON)
old_stdout = sys.stdout
sys.stdout = io.StringIO()
try:
formatter.emit_object({"status": "ok"})
finally:
sys.stdout = old_stdout
assert formatter._json_buffer == []


def test_human_workflow(capsys: pytest.CaptureFixture[str]) -> None:
"""Test complete HUMAN output workflow."""
formatter = OutputFormatter(OutputMode.HUMAN)
Expand Down