Skip to content

Commit ad99470

Browse files
committed
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
1 parent 3d7b951 commit ad99470

5 files changed

Lines changed: 134 additions & 12 deletions

File tree

src/tmuxp/_internal/colors.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,11 +43,14 @@
4343
from __future__ import annotations
4444

4545
import enum
46+
import logging
4647
import os
4748
import re
4849
import sys
4950
import typing as t
5051

52+
logger = logging.getLogger(__name__)
53+
5154
if t.TYPE_CHECKING:
5255
from typing import TypeAlias
5356

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

476+
def format_rule(self, width: int = 40, char: str = "─") -> str:
477+
"""Format a horizontal rule using Unicode box-drawing characters.
478+
479+
A richer alternative to ``format_separator()`` which uses plain hyphens.
480+
481+
Parameters
482+
----------
483+
width : int
484+
Number of characters. Default is 40.
485+
char : str
486+
Character to repeat. Default is ``"─"`` (U+2500).
487+
488+
Returns
489+
-------
490+
str
491+
Muted (blue) rule when colors enabled, plain rule otherwise.
492+
493+
Examples
494+
--------
495+
>>> colors = Colors(ColorMode.NEVER)
496+
>>> colors.format_rule(10)
497+
'──────────'
498+
>>> colors.format_rule(5, char="=")
499+
'====='
500+
"""
501+
return self.muted(char * width)
502+
473503
def format_kv(self, key: str, value: str) -> str:
474504
"""Format key: value pair with syntax highlighting.
475505

src/tmuxp/cli/_output.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,12 @@
2525

2626
import enum
2727
import json
28+
import logging
2829
import sys
2930
import typing as t
3031

32+
logger = logging.getLogger(__name__)
33+
3134

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

123+
def emit_object(self, data: dict[str, t.Any]) -> None:
124+
"""Emit a single top-level JSON object (not a list of records).
125+
126+
For commands that produce one structured object rather than a stream of
127+
records. Writes immediately without buffering; does not affect
128+
``_json_buffer``.
129+
130+
In JSON mode, writes indented JSON followed by a newline.
131+
In NDJSON mode, writes compact single-line JSON followed by a newline.
132+
In HUMAN mode, does nothing (use ``emit_text`` for human output).
133+
134+
Parameters
135+
----------
136+
data : dict
137+
The object to emit.
138+
139+
Examples
140+
--------
141+
>>> import io, sys
142+
>>> formatter = OutputFormatter(OutputMode.JSON)
143+
>>> formatter.emit_object({"status": "ok", "count": 3})
144+
{
145+
"status": "ok",
146+
"count": 3
147+
}
148+
>>> formatter._json_buffer # buffer is unaffected
149+
[]
150+
151+
>>> formatter2 = OutputFormatter(OutputMode.NDJSON)
152+
>>> formatter2.emit_object({"status": "ok", "count": 3})
153+
{"status": "ok", "count": 3}
154+
155+
>>> formatter3 = OutputFormatter(OutputMode.HUMAN)
156+
>>> formatter3.emit_object({"status": "ok"}) # no output in HUMAN mode
157+
"""
158+
if self.mode == OutputMode.JSON:
159+
sys.stdout.write(json.dumps(data, indent=2) + "\n")
160+
sys.stdout.flush()
161+
elif self.mode == OutputMode.NDJSON:
162+
sys.stdout.write(json.dumps(data) + "\n")
163+
sys.stdout.flush()
164+
# HUMAN: no-op
165+
120166
def finalize(self) -> None:
121167
"""Finalize output (flush JSON buffer if needed).
122168

src/tmuxp/cli/debug_info.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
import argparse
6+
import logging
67
import os
78
import pathlib
89
import platform
@@ -17,8 +18,11 @@
1718
from tmuxp._internal.private_path import PrivatePath, collapse_home_in_string
1819

1920
from ._colors import Colors, build_description, get_color_mode
21+
from ._output import OutputFormatter, OutputMode
2022
from .utils import tmuxp_echo
2123

24+
logger = logging.getLogger(__name__)
25+
2226
DEBUG_INFO_DESCRIPTION = build_description(
2327
"""
2428
Print diagnostic information for debugging and issue reports.
@@ -243,9 +247,6 @@ def command_debug_info(
243247
parser: argparse.ArgumentParser | None = None,
244248
) -> None:
245249
"""Entrypoint for ``tmuxp debug-info`` to print debug info to submit with issues."""
246-
import json
247-
import sys
248-
249250
# Get output mode
250251
output_json = args.output_json if args else False
251252

@@ -259,7 +260,6 @@ def command_debug_info(
259260
# Output based on mode
260261
if output_json:
261262
# Single object, not wrapped in array
262-
sys.stdout.write(json.dumps(data, indent=2) + "\n")
263-
sys.stdout.flush()
263+
OutputFormatter(OutputMode.JSON).emit_object(data)
264264
else:
265265
tmuxp_echo(_format_human_output(data, colors))

src/tmuxp/cli/ls.py

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
import argparse
3030
import datetime
3131
import json
32+
import logging
3233
import pathlib
3334
import typing as t
3435

@@ -46,6 +47,8 @@
4647
from ._colors import Colors, build_description, get_color_mode
4748
from ._output import OutputFormatter, OutputMode, get_output_mode
4849

50+
logger = logging.getLogger(__name__)
51+
4952
LS_DESCRIPTION = build_description(
5053
"""
5154
List workspace files in the tmuxp configuration directory.
@@ -567,9 +570,6 @@ def command_ls(
567570
--------
568571
>>> # command_ls() lists workspaces from cwd/parents and ~/.tmuxp/
569572
"""
570-
import json
571-
import sys
572-
573573
# Get color mode from args or default to AUTO
574574
color_mode = get_color_mode(args.color if args else None)
575575
colors = Colors(color_mode)
@@ -612,8 +612,7 @@ def command_ls(
612612
"workspaces": [],
613613
"global_workspace_dirs": global_dir_candidates,
614614
}
615-
sys.stdout.write(json.dumps(output_data, indent=2) + "\n")
616-
sys.stdout.flush()
615+
formatter.emit_object(output_data)
617616
# NDJSON: just output nothing for empty workspaces
618617
return
619618

@@ -623,8 +622,7 @@ def command_ls(
623622
"workspaces": workspaces,
624623
"global_workspace_dirs": global_dir_candidates,
625624
}
626-
sys.stdout.write(json.dumps(output_data, indent=2) + "\n")
627-
sys.stdout.flush()
625+
formatter.emit_object(output_data)
628626
return
629627

630628
# Human and NDJSON output

tests/cli/test_output.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,54 @@ def test_ndjson_workflow(capsys: pytest.CaptureFixture[str]) -> None:
221221
assert captured.out == ""
222222

223223

224+
def test_emit_object_json_writes_immediately(
225+
capsys: pytest.CaptureFixture[str],
226+
) -> None:
227+
"""JSON mode emit_object should write indented JSON immediately."""
228+
formatter = OutputFormatter(OutputMode.JSON)
229+
formatter.emit_object({"status": "ok", "count": 3})
230+
231+
captured = capsys.readouterr()
232+
data = json.loads(captured.out)
233+
assert data == {"status": "ok", "count": 3}
234+
# Indented output (indent=2)
235+
assert "\n" in captured.out
236+
237+
238+
def test_emit_object_ndjson_writes_compact(
239+
capsys: pytest.CaptureFixture[str],
240+
) -> None:
241+
"""NDJSON mode emit_object should write compact single-line JSON."""
242+
formatter = OutputFormatter(OutputMode.NDJSON)
243+
formatter.emit_object({"status": "ok", "count": 3})
244+
245+
captured = capsys.readouterr()
246+
lines = captured.out.strip().split("\n")
247+
assert len(lines) == 1
248+
assert json.loads(lines[0]) == {"status": "ok", "count": 3}
249+
250+
251+
def test_emit_object_human_silent(capsys: pytest.CaptureFixture[str]) -> None:
252+
"""HUMAN mode emit_object should produce no output."""
253+
formatter = OutputFormatter(OutputMode.HUMAN)
254+
formatter.emit_object({"status": "ok"})
255+
256+
captured = capsys.readouterr()
257+
assert captured.out == ""
258+
259+
260+
def test_emit_object_does_not_buffer() -> None:
261+
"""emit_object must not affect _json_buffer."""
262+
formatter = OutputFormatter(OutputMode.JSON)
263+
old_stdout = sys.stdout
264+
sys.stdout = io.StringIO()
265+
try:
266+
formatter.emit_object({"status": "ok"})
267+
finally:
268+
sys.stdout = old_stdout
269+
assert formatter._json_buffer == []
270+
271+
224272
def test_human_workflow(capsys: pytest.CaptureFixture[str]) -> None:
225273
"""Test complete HUMAN output workflow."""
226274
formatter = OutputFormatter(OutputMode.HUMAN)

0 commit comments

Comments
 (0)