Skip to content

Commit 71da68d

Browse files
tomasr8AA-Turnerpicnixzhugovk
authored
gh-131952: Add colour to the json module CLI (#132126)
Co-authored-by: Adam Turner <[email protected]> Co-authored-by: Bénédikt Tran <[email protected]> Co-authored-by: Hugo van Kemenade <[email protected]>
1 parent e7c5f60 commit 71da68d

File tree

4 files changed

+134
-10
lines changed

4 files changed

+134
-10
lines changed

Doc/whatsnew/3.14.rst

+6
Original file line numberDiff line numberDiff line change
@@ -825,6 +825,12 @@ json
825825
See the :ref:`JSON command-line interface <json-commandline>` documentation.
826826
(Contributed by Trey Hunner in :gh:`122873`.)
827827

828+
* By default, the output of the :ref:`JSON command-line interface <json-commandline>`
829+
is highlighted in color. This can be controlled via the
830+
:envvar:`PYTHON_COLORS` environment variable as well as the canonical
831+
|NO_COLOR|_ and |FORCE_COLOR|_ environment variables. See also
832+
:ref:`using-on-controlling-color`.
833+
(Contributed by Tomas Roun in :gh:`131952`.)
828834

829835
linecache
830836
---------

Lib/json/tool.py

+38-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,40 @@
55
"""
66
import argparse
77
import json
8+
import re
89
import sys
10+
from _colorize import ANSIColors, can_colorize
11+
12+
13+
# The string we are colorizing is valid JSON,
14+
# so we can use a looser but simpler regex to match
15+
# the various parts, most notably strings and numbers,
16+
# where the regex given by the spec is much more complex.
17+
_color_pattern = re.compile(r'''
18+
(?P<key>"(\\.|[^"\\])*")(?=:) |
19+
(?P<string>"(\\.|[^"\\])*") |
20+
(?P<boolean>true|false) |
21+
(?P<null>null)
22+
''', re.VERBOSE)
23+
24+
25+
_colors = {
26+
'key': ANSIColors.INTENSE_BLUE,
27+
'string': ANSIColors.BOLD_GREEN,
28+
'boolean': ANSIColors.BOLD_CYAN,
29+
'null': ANSIColors.BOLD_CYAN,
30+
}
31+
32+
33+
def _replace_match_callback(match):
34+
for key, color in _colors.items():
35+
if m := match.group(key):
36+
return f"{color}{m}{ANSIColors.RESET}"
37+
return match.group()
38+
39+
40+
def _colorize_json(json_str):
41+
return re.sub(_color_pattern, _replace_match_callback, json_str)
942

1043

1144
def main():
@@ -68,7 +101,11 @@ def main():
68101
outfile = open(options.outfile, 'w', encoding='utf-8')
69102
with outfile:
70103
for obj in objs:
71-
json.dump(obj, outfile, **dump_args)
104+
if can_colorize(file=outfile):
105+
json_str = json.dumps(obj, **dump_args)
106+
outfile.write(_colorize_json(json_str))
107+
else:
108+
json.dump(obj, outfile, **dump_args)
72109
outfile.write('\n')
73110
except ValueError as e:
74111
raise SystemExit(e)

Lib/test/test_json/test_tool.py

+89-9
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import subprocess
77

88
from test import support
9-
from test.support import os_helper
9+
from test.support import force_not_colorized, os_helper
1010
from test.support.script_helper import assert_python_ok
1111

1212

@@ -87,6 +87,7 @@ class TestMain(unittest.TestCase):
8787
}
8888
""")
8989

90+
@force_not_colorized
9091
def test_stdin_stdout(self):
9192
args = sys.executable, '-m', self.module
9293
process = subprocess.run(args, input=self.data, capture_output=True, text=True, check=True)
@@ -102,7 +103,8 @@ def _create_infile(self, data=None):
102103

103104
def test_infile_stdout(self):
104105
infile = self._create_infile()
105-
rc, out, err = assert_python_ok('-m', self.module, infile)
106+
rc, out, err = assert_python_ok('-m', self.module, infile,
107+
PYTHON_COLORS='0')
106108
self.assertEqual(rc, 0)
107109
self.assertEqual(out.splitlines(), self.expect.encode().splitlines())
108110
self.assertEqual(err, b'')
@@ -116,7 +118,8 @@ def test_non_ascii_infile(self):
116118
''').encode()
117119

118120
infile = self._create_infile(data)
119-
rc, out, err = assert_python_ok('-m', self.module, infile)
121+
rc, out, err = assert_python_ok('-m', self.module, infile,
122+
PYTHON_COLORS='0')
120123

121124
self.assertEqual(rc, 0)
122125
self.assertEqual(out.splitlines(), expect.splitlines())
@@ -125,7 +128,8 @@ def test_non_ascii_infile(self):
125128
def test_infile_outfile(self):
126129
infile = self._create_infile()
127130
outfile = os_helper.TESTFN + '.out'
128-
rc, out, err = assert_python_ok('-m', self.module, infile, outfile)
131+
rc, out, err = assert_python_ok('-m', self.module, infile, outfile,
132+
PYTHON_COLORS='0')
129133
self.addCleanup(os.remove, outfile)
130134
with open(outfile, "r", encoding="utf-8") as fp:
131135
self.assertEqual(fp.read(), self.expect)
@@ -135,33 +139,38 @@ def test_infile_outfile(self):
135139

136140
def test_writing_in_place(self):
137141
infile = self._create_infile()
138-
rc, out, err = assert_python_ok('-m', self.module, infile, infile)
142+
rc, out, err = assert_python_ok('-m', self.module, infile, infile,
143+
PYTHON_COLORS='0')
139144
with open(infile, "r", encoding="utf-8") as fp:
140145
self.assertEqual(fp.read(), self.expect)
141146
self.assertEqual(rc, 0)
142147
self.assertEqual(out, b'')
143148
self.assertEqual(err, b'')
144149

150+
@force_not_colorized
145151
def test_jsonlines(self):
146152
args = sys.executable, '-m', self.module, '--json-lines'
147153
process = subprocess.run(args, input=self.jsonlines_raw, capture_output=True, text=True, check=True)
148154
self.assertEqual(process.stdout, self.jsonlines_expect)
149155
self.assertEqual(process.stderr, '')
150156

151157
def test_help_flag(self):
152-
rc, out, err = assert_python_ok('-m', self.module, '-h')
158+
rc, out, err = assert_python_ok('-m', self.module, '-h',
159+
PYTHON_COLORS='0')
153160
self.assertEqual(rc, 0)
154161
self.assertTrue(out.startswith(b'usage: '))
155162
self.assertEqual(err, b'')
156163

157164
def test_sort_keys_flag(self):
158165
infile = self._create_infile()
159-
rc, out, err = assert_python_ok('-m', self.module, '--sort-keys', infile)
166+
rc, out, err = assert_python_ok('-m', self.module, '--sort-keys', infile,
167+
PYTHON_COLORS='0')
160168
self.assertEqual(rc, 0)
161169
self.assertEqual(out.splitlines(),
162170
self.expect_without_sort_keys.encode().splitlines())
163171
self.assertEqual(err, b'')
164172

173+
@force_not_colorized
165174
def test_indent(self):
166175
input_ = '[1, 2]'
167176
expect = textwrap.dedent('''\
@@ -175,6 +184,7 @@ def test_indent(self):
175184
self.assertEqual(process.stdout, expect)
176185
self.assertEqual(process.stderr, '')
177186

187+
@force_not_colorized
178188
def test_no_indent(self):
179189
input_ = '[1,\n2]'
180190
expect = '[1, 2]\n'
@@ -183,6 +193,7 @@ def test_no_indent(self):
183193
self.assertEqual(process.stdout, expect)
184194
self.assertEqual(process.stderr, '')
185195

196+
@force_not_colorized
186197
def test_tab(self):
187198
input_ = '[1, 2]'
188199
expect = '[\n\t1,\n\t2\n]\n'
@@ -191,6 +202,7 @@ def test_tab(self):
191202
self.assertEqual(process.stdout, expect)
192203
self.assertEqual(process.stderr, '')
193204

205+
@force_not_colorized
194206
def test_compact(self):
195207
input_ = '[ 1 ,\n 2]'
196208
expect = '[1,2]\n'
@@ -203,7 +215,8 @@ def test_no_ensure_ascii_flag(self):
203215
infile = self._create_infile('{"key":"💩"}')
204216
outfile = os_helper.TESTFN + '.out'
205217
self.addCleanup(os.remove, outfile)
206-
assert_python_ok('-m', self.module, '--no-ensure-ascii', infile, outfile)
218+
assert_python_ok('-m', self.module, '--no-ensure-ascii', infile,
219+
outfile, PYTHON_COLORS='0')
207220
with open(outfile, "rb") as f:
208221
lines = f.read().splitlines()
209222
# asserting utf-8 encoded output file
@@ -214,13 +227,14 @@ def test_ensure_ascii_default(self):
214227
infile = self._create_infile('{"key":"💩"}')
215228
outfile = os_helper.TESTFN + '.out'
216229
self.addCleanup(os.remove, outfile)
217-
assert_python_ok('-m', self.module, infile, outfile)
230+
assert_python_ok('-m', self.module, infile, outfile, PYTHON_COLORS='0')
218231
with open(outfile, "rb") as f:
219232
lines = f.read().splitlines()
220233
# asserting an ascii encoded output file
221234
expected = [b'{', rb' "key": "\ud83d\udca9"', b"}"]
222235
self.assertEqual(lines, expected)
223236

237+
@force_not_colorized
224238
@unittest.skipIf(sys.platform =="win32", "The test is failed with ValueError on Windows")
225239
def test_broken_pipe_error(self):
226240
cmd = [sys.executable, '-m', self.module]
@@ -232,7 +246,73 @@ def test_broken_pipe_error(self):
232246
proc.communicate(b'"{}"')
233247
self.assertEqual(proc.returncode, errno.EPIPE)
234248

249+
def test_colors(self):
250+
infile = os_helper.TESTFN
251+
self.addCleanup(os.remove, infile)
252+
253+
cases = (
254+
('{}', b'{}'),
255+
('[]', b'[]'),
256+
('null', b'\x1b[1;36mnull\x1b[0m'),
257+
('true', b'\x1b[1;36mtrue\x1b[0m'),
258+
('false', b'\x1b[1;36mfalse\x1b[0m'),
259+
('NaN', b'NaN'),
260+
('Infinity', b'Infinity'),
261+
('-Infinity', b'-Infinity'),
262+
('"foo"', b'\x1b[1;32m"foo"\x1b[0m'),
263+
(r'" \"foo\" "', b'\x1b[1;32m" \\"foo\\" "\x1b[0m'),
264+
('"α"', b'\x1b[1;32m"\\u03b1"\x1b[0m'),
265+
('123', b'123'),
266+
('-1.2345e+23', b'-1.2345e+23'),
267+
(r'{"\\": ""}',
268+
b'''\
269+
{
270+
\x1b[94m"\\\\"\x1b[0m: \x1b[1;32m""\x1b[0m
271+
}'''),
272+
(r'{"\\\\": ""}',
273+
b'''\
274+
{
275+
\x1b[94m"\\\\\\\\"\x1b[0m: \x1b[1;32m""\x1b[0m
276+
}'''),
277+
('''\
278+
{
279+
"foo": "bar",
280+
"baz": 1234,
281+
"qux": [true, false, null],
282+
"xyz": [NaN, -Infinity, Infinity]
283+
}''',
284+
b'''\
285+
{
286+
\x1b[94m"foo"\x1b[0m: \x1b[1;32m"bar"\x1b[0m,
287+
\x1b[94m"baz"\x1b[0m: 1234,
288+
\x1b[94m"qux"\x1b[0m: [
289+
\x1b[1;36mtrue\x1b[0m,
290+
\x1b[1;36mfalse\x1b[0m,
291+
\x1b[1;36mnull\x1b[0m
292+
],
293+
\x1b[94m"xyz"\x1b[0m: [
294+
NaN,
295+
-Infinity,
296+
Infinity
297+
]
298+
}'''),
299+
)
300+
301+
for input_, expected in cases:
302+
with self.subTest(input=input_):
303+
with open(infile, "w", encoding="utf-8") as fp:
304+
fp.write(input_)
305+
_, stdout, _ = assert_python_ok('-m', self.module, infile,
306+
PYTHON_COLORS='1')
307+
stdout = stdout.replace(b'\r\n', b'\n') # normalize line endings
308+
stdout = stdout.strip()
309+
self.assertEqual(stdout, expected)
310+
235311

236312
@support.requires_subprocess()
237313
class TestTool(TestMain):
238314
module = 'json.tool'
315+
316+
317+
if __name__ == "__main__":
318+
unittest.main()
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add color output to the :program:`json` CLI. Patch by Tomas Roun.

0 commit comments

Comments
 (0)