Skip to content

Commit 3ed4681

Browse files
authored
Add --color CLI flag, supporting "auto", "never", and "always" (#227)
* Use `--color=WHEN` style for color CLI argument * Fix failing pre-commit check * Address review suggestions - Use `click.Context.color` as the single source of truth for the color setting. - Set the color setting using a callback. - Add type hints to `tests/unit/test_cli_parse.py`. - Update text color tests to capture the Click context, and verify that its `color` attribute has the expected value. - Replaced `TextReporter._style` with `click.style`
1 parent e341e39 commit 3ed4681

File tree

4 files changed

+121
-46
lines changed

4 files changed

+121
-46
lines changed

src/check_jsonschema/cli.py

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,6 @@
2828
)
2929

3030

31-
def evaluate_environment_settings(ctx: click.Context) -> None:
32-
if os.getenv("NO_COLOR") is not None:
33-
ctx.color = False
34-
35-
3631
class SchemaLoadingMode(enum.Enum):
3732
filepath = "filepath"
3833
builtin = "builtin"
@@ -95,6 +90,17 @@ def format_opts(self) -> FormatOptions:
9590
)
9691

9792

93+
def set_color_mode(ctx: click.Context, param: str, value: str) -> None:
94+
if "NO_COLOR" in os.environ:
95+
ctx.color = False
96+
else:
97+
ctx.color = {
98+
"auto": None,
99+
"always": True,
100+
"never": False,
101+
}[value]
102+
103+
98104
@click.command(
99105
"check-jsonschema",
100106
help="""\
@@ -211,6 +217,14 @@ def format_opts(self) -> FormatOptions:
211217
type=click.Choice(tuple(REPORTER_BY_NAME.keys()), case_sensitive=False),
212218
default="text",
213219
)
220+
@click.option(
221+
"--color",
222+
help="Force or disable colorized output. Defaults to autodetection.",
223+
default="auto",
224+
type=click.Choice(("auto", "always", "never")),
225+
callback=set_color_mode,
226+
expose_value=False,
227+
)
214228
@click.option(
215229
"-v",
216230
"--verbose",
@@ -227,9 +241,7 @@ def format_opts(self) -> FormatOptions:
227241
count=True,
228242
)
229243
@click.argument("instancefiles", required=True, nargs=-1)
230-
@click.pass_context
231244
def main(
232-
ctx: click.Context,
233245
*,
234246
schemafile: str | None,
235247
builtin_schema: str | None,
@@ -248,7 +260,6 @@ def main(
248260
instancefiles: tuple[str, ...],
249261
) -> None:
250262
args = ParseResult()
251-
evaluate_environment_settings(ctx)
252263

253264
args.set_schema(schemafile, builtin_schema, check_metaschema)
254265
args.instancefiles = instancefiles

src/check_jsonschema/reporter.py

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -44,25 +44,17 @@ def __init__(
4444
*,
4545
verbosity: int,
4646
stream: t.TextIO | None = None, # default stream is stdout (None)
47-
color: bool = True,
4847
) -> None:
4948
super().__init__(verbosity=verbosity)
5049
self.stream = stream
51-
self.color = color
5250

5351
def _echo(self, s: str, *, indent: int = 0) -> None:
5452
click.echo(" " * indent + s, file=self.stream)
5553

56-
def _style(self, s: str, *, fg: str | None = None) -> str:
57-
if self.color:
58-
return click.style(s, fg=fg)
59-
else:
60-
return s
61-
6254
def report_success(self) -> None:
6355
if self.verbosity < 1:
6456
return
65-
ok = self._style("ok", fg="green")
57+
ok = click.style("ok", fg="green")
6658
self._echo(f"{ok} -- validation done")
6759

6860
def _format_validation_error_message(
@@ -71,8 +63,7 @@ def _format_validation_error_message(
7163
error_loc = err.json_path
7264
if filename:
7365
error_loc = f"{filename}::{error_loc}"
74-
if self.color:
75-
error_loc = self._style(error_loc, fg="yellow")
66+
error_loc = click.style(error_loc, fg="yellow")
7667
return f"{error_loc}: {err.message}"
7768

7869
def _show_validation_error(
@@ -95,7 +86,7 @@ def _show_validation_error(
9586

9687
def _show_parse_error(self, filename: str, err: ParseError) -> None:
9788
if self.verbosity < 2:
98-
self._echo(self._style(str(err), fg="yellow"), indent=2)
89+
self._echo(click.style(str(err), fg="yellow"), indent=2)
9990
elif self.verbosity < 3:
10091
self._echo(textwrap.indent(format_error(err, mode="short"), " "))
10192
else:

tests/unit/test_cli_parse.py

Lines changed: 89 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
1+
from __future__ import annotations
2+
3+
import typing as t
14
from unittest import mock
25

6+
import click
37
import pytest
48
from click.testing import CliRunner
9+
from click.testing import Result as ClickResult
510

611
from check_jsonschema import main as cli_main
7-
from check_jsonschema.cli import (
8-
ParseResult,
9-
SchemaLoadingMode,
10-
evaluate_environment_settings,
11-
)
12+
from check_jsonschema.cli import ParseResult, SchemaLoadingMode
1213

1314

1415
@pytest.fixture
@@ -26,10 +27,32 @@ def mock_cli_exec():
2627

2728

2829
@pytest.fixture
29-
def runner():
30+
def runner() -> CliRunner:
3031
return CliRunner(mix_stderr=False)
3132

3233

34+
def invoke_and_get_ctx(
35+
runner: CliRunner,
36+
cmd: click.Command,
37+
args: t.Sequence[str],
38+
) -> tuple[ClickResult, click.Context]:
39+
# There doesn't appear to be a good way to get the Click context used by a
40+
# test invocation, so we replace the invoke method with a wrapper that
41+
# calls `click.get_current_context` to extract the context object.
42+
43+
ctx = None
44+
45+
def extract_ctx(*args, **kwargs):
46+
nonlocal ctx
47+
ctx = click.get_current_context()
48+
return click.Command.invoke(*args, **kwargs)
49+
50+
with mock.patch("click.Command.invoke", extract_ctx):
51+
results = runner.invoke(cmd, args)
52+
53+
return results, ctx
54+
55+
3356
@pytest.mark.parametrize(
3457
"schemafile,builtin_schema,check_metaschema,expect_mode",
3558
[
@@ -55,34 +78,34 @@ def test_parse_result_set_schema(
5578
assert args.schema_path is None
5679

5780

58-
def test_requires_some_args(runner):
81+
def test_requires_some_args(runner: CliRunner):
5982
result = runner.invoke(cli_main, [])
6083
assert result.exit_code == 2
6184

6285

63-
def test_schemafile_and_instancefile(runner, mock_parse_result):
86+
def test_schemafile_and_instancefile(runner: CliRunner, mock_parse_result: ParseResult):
6487
runner.invoke(cli_main, ["--schemafile", "schema.json", "foo.json"])
6588
assert mock_parse_result.schema_mode == SchemaLoadingMode.filepath
6689
assert mock_parse_result.schema_path == "schema.json"
6790
assert mock_parse_result.instancefiles == ("foo.json",)
6891

6992

70-
def test_requires_at_least_one_instancefile(runner):
93+
def test_requires_at_least_one_instancefile(runner: CliRunner):
7194
result = runner.invoke(cli_main, ["--schemafile", "schema.json"])
7295
assert result.exit_code == 2
7396

7497

75-
def test_requires_schemafile(runner):
98+
def test_requires_schemafile(runner: CliRunner):
7699
result = runner.invoke(cli_main, ["foo.json"])
77100
assert result.exit_code == 2
78101

79102

80-
def test_no_cache_defaults_false(runner, mock_parse_result):
103+
def test_no_cache_defaults_false(runner: CliRunner, mock_parse_result: ParseResult):
81104
runner.invoke(cli_main, ["--schemafile", "schema.json", "foo.json"])
82105
assert mock_parse_result.disable_cache is False
83106

84107

85-
def test_no_cache_flag_is_true(runner, mock_parse_result):
108+
def test_no_cache_flag_is_true(runner: CliRunner, mock_parse_result: ParseResult):
86109
runner.invoke(cli_main, ["--schemafile", "schema.json", "foo.json", "--no-cache"])
87110
assert mock_parse_result.disable_cache is True
88111

@@ -119,7 +142,7 @@ def test_no_cache_flag_is_true(runner, mock_parse_result):
119142
],
120143
],
121144
)
122-
def test_mutex_schema_opts(runner, cmd_args):
145+
def test_mutex_schema_opts(runner: CliRunner, cmd_args: list[str]):
123146
result = runner.invoke(cli_main, cmd_args)
124147
assert result.exit_code == 2
125148
assert "are mutually exclusive" in result.stderr
@@ -133,22 +156,68 @@ def test_mutex_schema_opts(runner, cmd_args):
133156
["-h"],
134157
],
135158
)
136-
def test_supports_common_option(runner, cmd_args):
159+
def test_supports_common_option(runner: CliRunner, cmd_args: list[str]):
137160
result = runner.invoke(cli_main, cmd_args)
138161
assert result.exit_code == 0
139162

140163

141164
@pytest.mark.parametrize(
142165
"setting,expect_value", [(None, None), ("1", False), ("0", False)]
143166
)
144-
def test_no_color_env_var(monkeypatch, setting, expect_value):
145-
mock_ctx = mock.Mock()
146-
mock_ctx.color = None
147-
167+
def test_no_color_env_var(
168+
runner: CliRunner,
169+
monkeypatch: pytest.MonkeyPatch,
170+
setting: str | None,
171+
expect_value: bool | None,
172+
):
148173
if setting is None:
149174
monkeypatch.delenv("NO_COLOR", raising=False)
150175
else:
151176
monkeypatch.setenv("NO_COLOR", setting)
152177

153-
evaluate_environment_settings(mock_ctx)
154-
assert mock_ctx.color == expect_value
178+
_, ctx = invoke_and_get_ctx(
179+
runner, cli_main, ["--schemafile", "schema.json", "foo.json"]
180+
)
181+
assert ctx.color == expect_value
182+
183+
184+
@pytest.mark.parametrize(
185+
"setting,expected_value",
186+
[(None, None), ("auto", None), ("always", True), ("never", False)],
187+
)
188+
def test_color_cli_option(
189+
runner: CliRunner,
190+
setting: str | None,
191+
expected_value: bool | None,
192+
):
193+
args = ["--schemafile", "schema.json", "foo.json"]
194+
if setting:
195+
args.extend(("--color", setting))
196+
_, ctx = invoke_and_get_ctx(runner, cli_main, args)
197+
assert ctx.color == expected_value
198+
199+
200+
def test_no_color_env_var_overrides_cli_option(
201+
runner: CliRunner, monkeypatch: pytest.MonkeyPatch
202+
):
203+
monkeypatch.setenv("NO_COLOR", "1")
204+
205+
_, ctx = invoke_and_get_ctx(
206+
runner, cli_main, ["--color=always", "--schemafile", "schema.json", "foo.json"]
207+
)
208+
assert ctx.color is False
209+
210+
211+
@pytest.mark.parametrize(
212+
"setting,expected_value",
213+
[("auto", 0), ("always", 0), ("never", 0), ("anything_else", 2)],
214+
)
215+
def test_color_cli_option_is_choice(
216+
runner: CliRunner, setting: str, expected_value: int
217+
):
218+
assert (
219+
runner.invoke(
220+
cli_main, ["--color", setting, "--schemafile", "schema.json", "foo.json"]
221+
).exit_code
222+
== expected_value
223+
)

tests/unit/test_reporters.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ def _make_success_result():
1414
@pytest.mark.parametrize("verbosity", (0, 1, 2))
1515
@pytest.mark.parametrize("use_report_result_path", (False, True))
1616
def test_text_format_success(capsys, verbosity, use_report_result_path):
17-
reporter = TextReporter(color=False, verbosity=verbosity)
17+
reporter = TextReporter(verbosity=verbosity)
1818
if use_report_result_path:
1919
reporter.report_result(_make_success_result())
2020
else:
@@ -58,14 +58,18 @@ def test_text_format_validation_error_message_simple():
5858
)
5959
err = next(validator.iter_errors({"foo": {"bar": 1}}))
6060

61-
text_reporter = TextReporter(color=False, verbosity=1)
61+
text_reporter = TextReporter(verbosity=1)
6262
s1 = text_reporter._format_validation_error_message(err, filename="foo.json")
63-
assert (
64-
s1 == "foo.json::$.foo: {'bar': 1} is not valid under any of the given schemas"
63+
assert s1 == (
64+
"\x1b[33mfoo.json::$.foo\x1b[0m: {'bar': 1} "
65+
"is not valid under any of the given schemas"
6566
)
6667

6768
s2 = text_reporter._format_validation_error_message(err)
68-
assert s2 == "$.foo: {'bar': 1} is not valid under any of the given schemas"
69+
assert s2 == (
70+
"\x1b[33m$.foo\x1b[0m: {'bar': 1} "
71+
"is not valid under any of the given schemas"
72+
)
6973

7074

7175
@pytest.mark.parametrize("verbosity", (0, 1, 2))
@@ -104,7 +108,7 @@ def test_text_print_validation_error_nested(capsys, verbosity):
104108
result = CheckResult()
105109
result.record_validation_error("foo.json", err)
106110

107-
text_reporter = TextReporter(color=False, verbosity=verbosity)
111+
text_reporter = TextReporter(verbosity=verbosity)
108112
text_reporter.report_result(result)
109113
captured = capsys.readouterr()
110114
# nothing to stderr

0 commit comments

Comments
 (0)