Skip to content

Commit 01d3c43

Browse files
committed
feat(cli): auto-detect when to emit colors, honor NO_COLOR and FORCE_COLOR
ANSI escape codes no longer leak into piped output, files, log captures or CI artefacts — colors are auto-disabled when stdout/stderr isn't a TTY. The NO_COLOR and FORCE_COLOR environment variables are now respected. stdout and stderr are decided independently, so warnings keep their colour when stdout is piped but stderr is still on the terminal. Explicit \`--color\` / \`--no-color\` still override everything. Refs: https://no-color.org/
1 parent 7dc79ef commit 01d3c43

2 files changed

Lines changed: 42 additions & 13 deletions

File tree

packages/plugin/src/robotcode/plugin/__init__.py

Lines changed: 38 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -115,12 +115,35 @@ def show_diagnostics(self) -> bool:
115115
def show_diagnostics(self, value: bool) -> None:
116116
self._show_diagnostics = value
117117

118+
def color_for(self, file: Optional[IO[Any]] = None, err: bool = False) -> bool:
119+
"""Resolve the color decision for a specific output destination.
120+
121+
Honors the explicit ``--color`` / ``--no-color`` choice first, then the
122+
``FORCE_COLOR`` / ``NO_COLOR`` conventions (https://no-color.org/), and
123+
finally the TTY status of the relevant stream — which may be stdout,
124+
stderr, or an explicit ``file=``. This matters because stdout and
125+
stderr can be redirected independently: piping ``stdout`` into a file
126+
should not disable colour on warnings written to ``stderr`` if that
127+
is still attached to a terminal.
128+
"""
129+
pref = self.config.colored_output
130+
if pref == ColoredOutput.NO:
131+
return False
132+
if pref == ColoredOutput.YES:
133+
return True
134+
if os.environ.get("FORCE_COLOR"):
135+
return True
136+
if os.environ.get("NO_COLOR"):
137+
return False
138+
stream = file if file is not None else (sys.stderr if err else sys.stdout)
139+
isatty = getattr(stream, "isatty", None)
140+
return bool(isatty()) if callable(isatty) else False
141+
118142
@property
119143
def colored(self) -> bool:
120-
return self.config.colored_output in [
121-
ColoredOutput.AUTO,
122-
ColoredOutput.YES,
123-
]
144+
"""Shortcut for the color decision on stdout — used for branching
145+
between rich and plain rendering paths that always target stdout."""
146+
return self.color_for()
124147

125148
@property
126149
def has_rich(self) -> bool:
@@ -139,12 +162,13 @@ def verbose(
139162
err: Optional[bool] = True,
140163
) -> None:
141164
if self.config.verbose:
165+
err_resolved = err if err is not None else True
142166
click.secho(
143167
message() if callable(message) else message,
144168
file=file,
145169
nl=nl if nl is not None else True,
146-
err=err if err is not None else True,
147-
color=self.colored,
170+
err=err_resolved,
171+
color=self.color_for(file=file, err=err_resolved),
148172
fg="bright_black",
149173
)
150174

@@ -167,7 +191,7 @@ def progressbar(
167191
show_percent=show_percent,
168192
show_pos=show_pos,
169193
file=sys.stderr,
170-
color=self.colored,
194+
color=self.color_for(file=sys.stderr),
171195
)
172196

173197
def warning(
@@ -177,12 +201,13 @@ def warning(
177201
nl: Optional[bool] = True,
178202
err: Optional[bool] = True,
179203
) -> None:
204+
err_resolved = err if err is not None else True
180205
click.secho(
181206
f"[ {click.style('WARN', fg='yellow')} ] {message() if callable(message) else message}",
182207
file=file,
183208
nl=nl if nl is not None else True,
184-
err=err if err is not None else True,
185-
color=self.colored,
209+
err=err_resolved,
210+
color=self.color_for(file=file, err=err_resolved),
186211
fg="bright_yellow",
187212
)
188213

@@ -193,12 +218,13 @@ def error(
193218
nl: Optional[bool] = True,
194219
err: Optional[bool] = True,
195220
) -> None:
221+
err_resolved = err if err is not None else True
196222
click.secho(
197223
f"[ {click.style('ERROR', fg='red')} ] {message() if callable(message) else message}",
198224
file=file,
199225
nl=nl if nl is not None else True,
200-
err=err if err is not None else True,
201-
color=self.colored,
226+
err=err_resolved,
227+
color=self.color_for(file=file, err=err_resolved),
202228
)
203229

204230
def print_data(
@@ -267,7 +293,7 @@ def echo(
267293
message() if callable(message) else message,
268294
file=file,
269295
nl=nl,
270-
color=self.colored,
296+
color=self.color_for(file=file, err=err),
271297
err=err,
272298
)
273299

src/robotcode/cli/__init__.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,10 @@ def __init__(self, *args: Any, defaults: Any = None, **kwargs: Any) -> None:
105105
"--color / --no-color",
106106
"color",
107107
default=None,
108-
help="Whether or not to display colored output (default is auto-detection).",
108+
help=(
109+
"Force or disable colored output. Default (no flag): auto-detect — colors only when stdout is a TTY,"
110+
" disabled if `NO_COLOR` is set, forced if `FORCE_COLOR` is set."
111+
),
109112
show_envvar=True,
110113
)
111114
@click.option(

0 commit comments

Comments
 (0)