Skip to content

Commit 7029b4c

Browse files
committed
Merge branch 'color-output-option' into color-output-2
2 parents d1c6904 + 89c0c93 commit 7029b4c

File tree

7 files changed

+203
-49
lines changed

7 files changed

+203
-49
lines changed

.github/workflows/test.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,14 +111,14 @@ jobs:
111111
env:
112112
TOX_SKIP_MISSING_INTERPRETERS: False
113113
# Rich (pip) -- Disable color for windows + pytest
114-
FORCE_COLOR: ${{ !(startsWith(matrix.os, 'windows-') && startsWith(matrix.toxenv, 'py')) && 1 || 0 }}
114+
#FORCE_COLOR: ${{ !(startsWith(matrix.os, 'windows-') && startsWith(matrix.toxenv, 'py')) && 1 || 0 }}
115115
# Tox
116116
PY_COLORS: 1
117117
# Python -- Disable argparse help colors (3.14+)
118118
PYTHON_COLORS: 0
119119
# Mypy (see https://github.com/python/mypy/issues/7771)
120120
TERM: xterm-color
121-
MYPY_FORCE_COLOR: 1
121+
#MYPY_FORCE_COLOR: 1
122122
MYPY_FORCE_TERMINAL_WIDTH: 200
123123
# Pytest
124124
PYTEST_ADDOPTS: --color=yes

docs/source/command_line.rst

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -923,9 +923,27 @@ in error messages.
923923
Use visually nicer output in error messages: use soft word wrap,
924924
show source code snippets, and show error location markers.
925925

926+
.. option:: --color-output[=auto]
927+
928+
Enables colored output in error messages.
929+
930+
When ``--color-output=auto`` is given, uses colored output if the output
931+
(both stdout and stderr) is going to a tty. This is also the default.
932+
933+
.. note::
934+
When the environment variable ``MYPY_FORCE_COLOR`` is set to a
935+
non-``0`` non-empty string, mypy always enables colored output
936+
(even if ``--no-color-output`` is given).
937+
938+
.. Note: Here I decide not to document ``FORCE_COLOR`` as its
939+
logic seems counter-intuitive from earlier conventions
940+
(PR13853)
941+
926942
.. option:: --no-color-output
927943

928-
This flag will disable color output in error messages, enabled by default.
944+
Disables colored output in error messages.
945+
946+
See also note above.
929947

930948
.. option:: --no-error-summary
931949

mypy/dmypy_server.py

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,13 @@
1818
import traceback
1919
from collections.abc import Sequence, Set as AbstractSet
2020
from contextlib import redirect_stderr, redirect_stdout
21-
from typing import Any, Callable, Final
21+
from typing import Any, Callable, Final, Literal, cast
2222
from typing_extensions import TypeAlias as _TypeAlias
2323

2424
import mypy.build
2525
import mypy.errors
2626
import mypy.main
27+
from mypy import util
2728
from mypy.dmypy_util import WriteToConn, receive, send
2829
from mypy.find_sources import InvalidSourceList, create_source_list
2930
from mypy.fscache import FileSystemCache
@@ -199,9 +200,18 @@ def __init__(self, options: Options, status_file: str, timeout: int | None = Non
199200
options.local_partial_types = True
200201
self.status_file = status_file
201202

203+
# Type annotation needed for mypy (Pyright understands this)
204+
use_color: bool | Literal["auto"] = (
205+
True
206+
if util.should_force_color()
207+
else ("auto" if options.color_output == "auto" else cast(bool, options.color_output))
208+
)
209+
202210
# Since the object is created in the parent process we can check
203211
# the output terminal options here.
204-
self.formatter = FancyFormatter(sys.stdout, sys.stderr, options.hide_error_codes)
212+
self.formatter = FancyFormatter(
213+
sys.stdout, sys.stderr, options.hide_error_codes, color_request=use_color
214+
)
205215

206216
def _response_metadata(self) -> dict[str, str]:
207217
py_version = f"{self.options.python_version[0]}_{self.options.python_version[1]}"
@@ -841,7 +851,13 @@ def pretty_messages(
841851
is_tty: bool = False,
842852
terminal_width: int | None = None,
843853
) -> list[str]:
844-
use_color = self.options.color_output and is_tty
854+
use_color = (
855+
True
856+
if util.should_force_color()
857+
else (
858+
is_tty if self.options.color_output == "auto" else bool(self.options.color_output)
859+
)
860+
)
845861
fit_width = self.options.pretty and is_tty
846862
if fit_width:
847863
messages = self.formatter.fit_in_terminal(

mypy/main.py

Lines changed: 64 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from collections.abc import Sequence
1212
from gettext import gettext
1313
from io import TextIOWrapper
14-
from typing import IO, TYPE_CHECKING, Any, Final, NoReturn, TextIO
14+
from typing import IO, TYPE_CHECKING, Any, Final, Literal, NoReturn, TextIO, cast
1515

1616
from mypy import build, defaults, state, util
1717
from mypy.config_parser import (
@@ -90,8 +90,19 @@ def main(
9090
if clean_exit:
9191
options.fast_exit = False
9292

93+
# Type annotation needed for mypy (Pyright understands this)
94+
use_color: bool | Literal["auto"] = (
95+
True
96+
if util.should_force_color()
97+
else ("auto" if options.color_output == "auto" else cast(bool, options.color_output))
98+
)
99+
93100
formatter = util.FancyFormatter(
94-
stdout, stderr, options.hide_error_codes, hide_success=bool(options.output)
101+
stdout,
102+
stderr,
103+
options.hide_error_codes,
104+
hide_success=bool(options.output),
105+
color_request=use_color,
95106
)
96107

97108
if options.allow_redefinition_new and not options.local_partial_types:
@@ -124,7 +135,7 @@ def main(
124135
install_types(formatter, options, non_interactive=options.non_interactive)
125136
return
126137

127-
res, messages, blockers = run_build(sources, options, fscache, t0, stdout, stderr)
138+
res, messages, blockers = run_build(sources, options, fscache, t0, stdout, stderr, use_color)
128139

129140
if options.non_interactive:
130141
missing_pkgs = read_types_packages_to_install(options.cache_dir, after_run=True)
@@ -133,8 +144,10 @@ def main(
133144
install_types(formatter, options, after_run=True, non_interactive=True)
134145
fscache.flush()
135146
print()
136-
res, messages, blockers = run_build(sources, options, fscache, t0, stdout, stderr)
137-
show_messages(messages, stderr, formatter, options)
147+
res, messages, blockers = run_build(
148+
sources, options, fscache, t0, stdout, stderr, use_color
149+
)
150+
show_messages(messages, stderr, formatter, options, use_color)
138151

139152
if MEM_PROFILE:
140153
from mypy.memprofile import print_memory_profile
@@ -148,12 +161,12 @@ def main(
148161
if options.error_summary:
149162
if n_errors:
150163
summary = formatter.format_error(
151-
n_errors, n_files, len(sources), blockers=blockers, use_color=options.color_output
164+
n_errors, n_files, len(sources), blockers=blockers, use_color=use_color
152165
)
153166
stdout.write(summary + "\n")
154167
# Only notes should also output success
155168
elif not messages or n_notes == len(messages):
156-
stdout.write(formatter.format_success(len(sources), options.color_output) + "\n")
169+
stdout.write(formatter.format_success(len(sources), use_color) + "\n")
157170
stdout.flush()
158171

159172
if options.install_types and not options.non_interactive:
@@ -182,9 +195,14 @@ def run_build(
182195
t0: float,
183196
stdout: TextIO,
184197
stderr: TextIO,
198+
use_color: bool | Literal["auto"],
185199
) -> tuple[build.BuildResult | None, list[str], bool]:
186200
formatter = util.FancyFormatter(
187-
stdout, stderr, options.hide_error_codes, hide_success=bool(options.output)
201+
stdout,
202+
stderr,
203+
options.hide_error_codes,
204+
hide_success=bool(options.output),
205+
color_request=use_color,
188206
)
189207

190208
messages = []
@@ -200,7 +218,7 @@ def flush_errors(filename: str | None, new_messages: list[str], serious: bool) -
200218
# Collect messages and possibly show them later.
201219
return
202220
f = stderr if serious else stdout
203-
show_messages(new_messages, f, formatter, options)
221+
show_messages(new_messages, f, formatter, options, use_color)
204222

205223
serious = False
206224
blockers = False
@@ -238,10 +256,16 @@ def flush_errors(filename: str | None, new_messages: list[str], serious: bool) -
238256

239257

240258
def show_messages(
241-
messages: list[str], f: TextIO, formatter: util.FancyFormatter, options: Options
259+
messages: list[str],
260+
f: TextIO,
261+
formatter: util.FancyFormatter,
262+
options: Options,
263+
use_color: bool | Literal["auto"],
242264
) -> None:
265+
if use_color == "auto":
266+
use_color = formatter.default_colored
243267
for msg in messages:
244-
if options.color_output:
268+
if use_color:
245269
msg = formatter.colorize(msg)
246270
f.write(msg + "\n")
247271
f.flush()
@@ -462,6 +486,19 @@ def __call__(
462486
parser.exit()
463487

464488

489+
# Coupled with the usage in define_options
490+
class ColorOutputAction(argparse.Action):
491+
def __call__(
492+
self,
493+
parser: argparse.ArgumentParser,
494+
namespace: argparse.Namespace,
495+
values: str | Sequence[Any] | None,
496+
option_string: str | None = None,
497+
) -> None:
498+
assert values in ("auto", None)
499+
setattr(namespace, self.dest, True if values is None else "auto")
500+
501+
465502
def define_options(
466503
program: str = "mypy",
467504
header: str = HEADER,
@@ -993,13 +1030,22 @@ def add_invertible_flag(
9931030
" and show error location markers",
9941031
group=error_group,
9951032
)
996-
add_invertible_flag(
997-
"--no-color-output",
1033+
# XXX Setting default doesn't seem to work unless I change
1034+
# the attribute in options.Options
1035+
error_group.add_argument(
1036+
"--color-output",
9981037
dest="color_output",
999-
default=True,
1000-
help="Do not colorize error messages",
1001-
group=error_group,
1038+
action=ColorOutputAction,
1039+
nargs="?",
1040+
choices=["auto"],
1041+
help="Colorize error messages (inverse: --no-color-output). "
1042+
"Detects if to use color when option is omitted and --no-color-output "
1043+
"is not given, or when --color-output=auto",
1044+
)
1045+
error_group.add_argument(
1046+
"--no-color-output", dest="color_output", action="store_false", help=argparse.SUPPRESS
10021047
)
1048+
# error_group.set_defaults(color_output="auto")
10031049
add_invertible_flag(
10041050
"--no-error-summary",
10051051
dest="error_summary",
@@ -1530,7 +1576,8 @@ def set_strict_flags() -> None:
15301576
reason = cache.find_module(p)
15311577
if reason is ModuleNotFoundReason.FOUND_WITHOUT_TYPE_HINTS:
15321578
fail(
1533-
f"Package '{p}' cannot be type checked due to missing py.typed marker. See https://mypy.readthedocs.io/en/stable/installed_packages.html for more details",
1579+
f"Package '{p}' cannot be type checked due to missing py.typed marker. "
1580+
"See https://mypy.readthedocs.io/en/stable/installed_packages.html for more details",
15341581
stderr,
15351582
options,
15361583
)

mypy/options.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,7 @@ def __init__(self) -> None:
206206
self.show_error_context = False
207207

208208
# Use nicer output (when possible).
209-
self.color_output = True
209+
self.color_output = "auto"
210210
self.error_summary = True
211211

212212
# Assume arguments with default values of None are Optional

mypy/test/test_color_output.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
from functools import partial
2+
from subprocess import run
3+
from typing import Any
4+
5+
import pytest
6+
7+
# TODO Would like help with this test, how do I make it runnable?
8+
9+
10+
def test(expect_color: bool, *args: Any, **kwargs: Any) -> None:
11+
res = run(*args, capture_output=True, **kwargs)
12+
if "Found" not in res.stdout: # ??
13+
pytest.fail("Command failed to complete or did not detect type error")
14+
if expect_color: # Expect color control chars
15+
assert "<string>:1: error:" not in res.stdout
16+
assert "\nFound" not in res.stdout
17+
else: # Expect no color control chars
18+
assert "<string>:1: error:" in res.stdout
19+
assert "\nFound" in res.stdout
20+
21+
22+
colored = partial(test, True)
23+
not_colored = partial(test, False)
24+
25+
26+
@pytest.mark.parametrize("command", ["mypy", "dmypy run --"])
27+
def test_color_output(command: str) -> None:
28+
# Note: Though we don't check stderr, capturing it is useful
29+
# because it provides traceback if mypy crashes due to exception
30+
# and pytest reveals it upon failure (?)
31+
not_colored(f"{command} -c \"1+'a'\"")
32+
colored(f"{command} -c \"1+'a'\"", env={"MYPY_FORCE_COLOR": "1"})
33+
colored(f"{command} -c \"1+'a'\" --color-output")
34+
not_colored(f"{command} -c \"1+'a'\" --no-color-output")
35+
colored(f"{command} -c \"1+'a'\" --no-color-output", env={"MYPY_FORCE_COLOR": "1"}) # TODO
36+
37+
38+
# TODO: Tests in the terminal (require manual testing?)
39+
"""
40+
In the terminal:
41+
colored: mypy -c "1+'a'"
42+
colored: mypy -c "1+'a'" --color-output
43+
not colored: mypy -c "1+'a'" --no-color-output
44+
colored: mypy -c "1+'a'" --color-output (with MYPY_FORCE_COLOR=1)
45+
colored: mypy -c "1+'a'" --no-color-output (with MYPY_FORCE_COLOR=1)
46+
47+
To test, save this as a .bat and run in a Windows terminal (I don't know the Unix equivalent):
48+
49+
set MYPY_FORCE_COLOR=
50+
mypy -c "1+'a'"
51+
mypy -c "1+'a'" --color-output
52+
mypy -c "1+'a'" --no-color-output
53+
set MYPY_FORCE_COLOR=1
54+
mypy -c "1+'a'" --color-output
55+
mypy -c "1+'a'" --no-color-output
56+
set MYPY_FORCE_COLOR=
57+
"""

0 commit comments

Comments
 (0)