Skip to content

gh-131952: Add color to the json CLI #132126

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 32 commits into from
Apr 19, 2025
Merged
Show file tree
Hide file tree
Changes from 27 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
cae293f
Add color to the json.tool CLI output
tomasr8 Apr 5, 2025
1854ae5
Add news entry
tomasr8 Apr 5, 2025
ee39cb2
Fix escaped quotes
tomasr8 Apr 5, 2025
5e726ee
Fix tests
tomasr8 Apr 5, 2025
848a7be
Fix the tests for real this time
tomasr8 Apr 5, 2025
8d90ccb
Fix tests on Windows
tomasr8 Apr 5, 2025
93e4306
Sort imports
tomasr8 Apr 5, 2025
f8d697a
Fix string regex
tomasr8 Apr 5, 2025
429e350
Handle NaN & Infinity
tomasr8 Apr 6, 2025
0abe76a
Use only digits in number regex
tomasr8 Apr 6, 2025
1acd35d
Remove non-greedy matching in string regex
tomasr8 Apr 6, 2025
691aecd
Test unicode
tomasr8 Apr 6, 2025
5afac97
Pass the file to `can_colorize`
tomasr8 Apr 6, 2025
911f75f
Make the test file runnable
tomasr8 Apr 6, 2025
4c27be7
Use force_not_colorized
tomasr8 Apr 6, 2025
18ce6fa
Remove unused variable
tomasr8 Apr 6, 2025
18f7ae5
🦭
tomasr8 Apr 6, 2025
b5157af
Add a comment to the color regex
tomasr8 Apr 6, 2025
d7287e2
Move helper functions to the start
tomasr8 Apr 6, 2025
177107b
Make helper functions private
tomasr8 Apr 6, 2025
b7589be
Prefer global functions
tomasr8 Apr 6, 2025
e744290
Remove redundant comments
tomasr8 Apr 6, 2025
fac39c7
Improve news entry
tomasr8 Apr 6, 2025
4f399be
Highlight keys in a different color
tomasr8 Apr 15, 2025
8bd0a36
Improve color contrast
tomasr8 Apr 15, 2025
37d4c08
Tone down the colors a bit
tomasr8 Apr 15, 2025
1e6f4ea
Use bold colors & fix tests
tomasr8 Apr 15, 2025
cc92518
Use default color for numbers
tomasr8 Apr 19, 2025
4fc5e27
Add What's New entry
tomasr8 Apr 19, 2025
a489649
Simplify code
tomasr8 Apr 19, 2025
789ca88
Lint fix
tomasr8 Apr 19, 2025
ce75f86
Fix typo
tomasr8 Apr 19, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 41 additions & 1 deletion Lib/json/tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,43 @@
"""
import argparse
import json
import re
import sys
from _colorize import ANSIColors, can_colorize


# The string we are colorizing is a valid JSON,
# so we can use a looser but simpler regex to match
# the various parts, most notably strings and numbers,
# where the regex given by the spec is much more complex.
_color_pattern = re.compile(r'''
(?P<key>"(\\.|[^"\\])*")(?=:) |
(?P<string>"(\\.|[^"\\])*") |
(?P<number>NaN|-?Infinity|[0-9\-+.Ee]+) |
(?P<boolean>true|false) |
(?P<null>null)
''', re.VERBOSE)


_colors = {
'key': ANSIColors.INTENSE_BLUE,
'string': ANSIColors.BOLD_GREEN,
'number': ANSIColors.BOLD_YELLOW,
'boolean': ANSIColors.BOLD_CYAN,
'null': ANSIColors.BOLD_CYAN,
}


def _replace_match_callback(match):
for key in _colors:
if m := match.group(key):
color = _colors[key]
return f"{color}{m}{ANSIColors.RESET}"
return match.group()


def _colorize_json(json_str):
return re.sub(_color_pattern, _replace_match_callback, json_str)


def main():
Expand Down Expand Up @@ -68,7 +104,11 @@ def main():
outfile = open(options.outfile, 'w', encoding='utf-8')
with outfile:
for obj in objs:
json.dump(obj, outfile, **dump_args)
if can_colorize(file=outfile):
json_str = json.dumps(obj, **dump_args)
outfile.write(_colorize_json(json_str))
else:
json.dump(obj, outfile, **dump_args)
outfile.write('\n')
except ValueError as e:
raise SystemExit(e)
Expand Down
98 changes: 89 additions & 9 deletions Lib/test/test_json/test_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import subprocess

from test import support
from test.support import os_helper
from test.support import force_not_colorized, os_helper
from test.support.script_helper import assert_python_ok


Expand Down Expand Up @@ -87,6 +87,7 @@ class TestMain(unittest.TestCase):
}
""")

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

def test_infile_stdout(self):
infile = self._create_infile()
rc, out, err = assert_python_ok('-m', self.module, infile)
rc, out, err = assert_python_ok('-m', self.module, infile,
PYTHON_COLORS='0')
self.assertEqual(rc, 0)
self.assertEqual(out.splitlines(), self.expect.encode().splitlines())
self.assertEqual(err, b'')
Expand All @@ -116,7 +118,8 @@ def test_non_ascii_infile(self):
''').encode()

infile = self._create_infile(data)
rc, out, err = assert_python_ok('-m', self.module, infile)
rc, out, err = assert_python_ok('-m', self.module, infile,
PYTHON_COLORS='0')

self.assertEqual(rc, 0)
self.assertEqual(out.splitlines(), expect.splitlines())
Expand All @@ -125,7 +128,8 @@ def test_non_ascii_infile(self):
def test_infile_outfile(self):
infile = self._create_infile()
outfile = os_helper.TESTFN + '.out'
rc, out, err = assert_python_ok('-m', self.module, infile, outfile)
rc, out, err = assert_python_ok('-m', self.module, infile, outfile,
PYTHON_COLORS='0')
self.addCleanup(os.remove, outfile)
with open(outfile, "r", encoding="utf-8") as fp:
self.assertEqual(fp.read(), self.expect)
Expand All @@ -135,33 +139,38 @@ def test_infile_outfile(self):

def test_writing_in_place(self):
infile = self._create_infile()
rc, out, err = assert_python_ok('-m', self.module, infile, infile)
rc, out, err = assert_python_ok('-m', self.module, infile, infile,
PYTHON_COLORS='0')
with open(infile, "r", encoding="utf-8") as fp:
self.assertEqual(fp.read(), self.expect)
self.assertEqual(rc, 0)
self.assertEqual(out, b'')
self.assertEqual(err, b'')

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

def test_help_flag(self):
rc, out, err = assert_python_ok('-m', self.module, '-h')
rc, out, err = assert_python_ok('-m', self.module, '-h',
PYTHON_COLORS='0')
self.assertEqual(rc, 0)
self.assertTrue(out.startswith(b'usage: '))
self.assertEqual(err, b'')

def test_sort_keys_flag(self):
infile = self._create_infile()
rc, out, err = assert_python_ok('-m', self.module, '--sort-keys', infile)
rc, out, err = assert_python_ok('-m', self.module, '--sort-keys', infile,
PYTHON_COLORS='0')
self.assertEqual(rc, 0)
self.assertEqual(out.splitlines(),
self.expect_without_sort_keys.encode().splitlines())
self.assertEqual(err, b'')

@force_not_colorized
def test_indent(self):
input_ = '[1, 2]'
expect = textwrap.dedent('''\
Expand All @@ -175,6 +184,7 @@ def test_indent(self):
self.assertEqual(process.stdout, expect)
self.assertEqual(process.stderr, '')

@force_not_colorized
def test_no_indent(self):
input_ = '[1,\n2]'
expect = '[1, 2]\n'
Expand All @@ -183,6 +193,7 @@ def test_no_indent(self):
self.assertEqual(process.stdout, expect)
self.assertEqual(process.stderr, '')

@force_not_colorized
def test_tab(self):
input_ = '[1, 2]'
expect = '[\n\t1,\n\t2\n]\n'
Expand All @@ -191,6 +202,7 @@ def test_tab(self):
self.assertEqual(process.stdout, expect)
self.assertEqual(process.stderr, '')

@force_not_colorized
def test_compact(self):
input_ = '[ 1 ,\n 2]'
expect = '[1,2]\n'
Expand All @@ -203,7 +215,8 @@ def test_no_ensure_ascii_flag(self):
infile = self._create_infile('{"key":"💩"}')
outfile = os_helper.TESTFN + '.out'
self.addCleanup(os.remove, outfile)
assert_python_ok('-m', self.module, '--no-ensure-ascii', infile, outfile)
assert_python_ok('-m', self.module, '--no-ensure-ascii', infile,
outfile, PYTHON_COLORS='0')
with open(outfile, "rb") as f:
lines = f.read().splitlines()
# asserting utf-8 encoded output file
Expand All @@ -214,13 +227,14 @@ def test_ensure_ascii_default(self):
infile = self._create_infile('{"key":"💩"}')
outfile = os_helper.TESTFN + '.out'
self.addCleanup(os.remove, outfile)
assert_python_ok('-m', self.module, infile, outfile)
assert_python_ok('-m', self.module, infile, outfile, PYTHON_COLORS='0')
with open(outfile, "rb") as f:
lines = f.read().splitlines()
# asserting an ascii encoded output file
expected = [b'{', rb' "key": "\ud83d\udca9"', b"}"]
self.assertEqual(lines, expected)

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

def test_colors(self):
infile = os_helper.TESTFN
self.addCleanup(os.remove, infile)

cases = (
('{}', b'{}'),
('[]', b'[]'),
('null', b'\x1b[1;36mnull\x1b[0m'),
('true', b'\x1b[1;36mtrue\x1b[0m'),
('false', b'\x1b[1;36mfalse\x1b[0m'),
('NaN', b'\x1b[1;33mNaN\x1b[0m'),
('Infinity', b'\x1b[1;33mInfinity\x1b[0m'),
('-Infinity', b'\x1b[1;33m-Infinity\x1b[0m'),
('"foo"', b'\x1b[1;32m"foo"\x1b[0m'),
(r'" \"foo\" "', b'\x1b[1;32m" \\"foo\\" "\x1b[0m'),
('"α"', b'\x1b[1;32m"\\u03b1"\x1b[0m'),
('123', b'\x1b[1;33m123\x1b[0m'),
('-1.2345e+23', b'\x1b[1;33m-1.2345e+23\x1b[0m'),
(r'{"\\": ""}',
b'''\
{
\x1b[94m"\\\\"\x1b[0m: \x1b[1;32m""\x1b[0m
}'''),
(r'{"\\\\": ""}',
b'''\
{
\x1b[94m"\\\\\\\\"\x1b[0m: \x1b[1;32m""\x1b[0m
}'''),
('''\
{
"foo": "bar",
"baz": 1234,
"qux": [true, false, null],
"xyz": [NaN, -Infinity, Infinity]
}''',
b'''\
{
\x1b[94m"foo"\x1b[0m: \x1b[1;32m"bar"\x1b[0m,
\x1b[94m"baz"\x1b[0m: \x1b[1;33m1234\x1b[0m,
\x1b[94m"qux"\x1b[0m: [
\x1b[1;36mtrue\x1b[0m,
\x1b[1;36mfalse\x1b[0m,
\x1b[1;36mnull\x1b[0m
],
\x1b[94m"xyz"\x1b[0m: [
\x1b[1;33mNaN\x1b[0m,
\x1b[1;33m-Infinity\x1b[0m,
\x1b[1;33mInfinity\x1b[0m
]
}'''),
)

for input_, expected in cases:
with self.subTest(input=input_):
with open(infile, "w", encoding="utf-8") as fp:
fp.write(input_)
_, stdout, _ = assert_python_ok('-m', self.module, infile,
PYTHON_COLORS='1')
stdout = stdout.replace(b'\r\n', b'\n') # normalize line endings
stdout = stdout.strip()
self.assertEqual(stdout, expected)


@support.requires_subprocess()
class TestTool(TestMain):
module = 'json.tool'


if __name__ == "__main__":
unittest.main()
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add color output to the :program:`json` CLI.
Loading