Skip to content
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

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

Open
wants to merge 23 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
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
36 changes: 35 additions & 1 deletion Lib/json/tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
"""
import argparse
import json
import re
import sys
from _colorize import ANSIColors, can_colorize


def main():
Expand Down Expand Up @@ -48,6 +50,8 @@ def main():
dump_args['indent'] = None
dump_args['separators'] = ',', ':'

with_colors = can_colorize()

try:
if options.infile == '-':
infile = sys.stdin
Expand All @@ -68,12 +72,42 @@ def main():
outfile = open(options.outfile, 'w', encoding='utf-8')
with outfile:
for obj in objs:
json.dump(obj, outfile, **dump_args)
if with_colors:
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)


color_pattern = re.compile(r'''
(?P<string>"(\\.|[^"\\])*?") | # String
(?P<number>[\d\-+.Ee]+) | # Number
(?P<boolean>true|false) | # Boolean
(?P<null>null) # Null
''', re.VERBOSE)


def colorize_json(json_str):
colors = {
'string': ANSIColors.GREEN,
'number': ANSIColors.YELLOW,
'boolean': ANSIColors.CYAN,
'null': ANSIColors.CYAN,
}

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

return re.sub(color_pattern, replace, json_str)


if __name__ == '__main__':
try:
main()
Expand Down
85 changes: 77 additions & 8 deletions Lib/test/test_json/test_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@
from test.support.script_helper import assert_python_ok


def no_color(func):
def inner(*args, **kwargs):
with os_helper.EnvironmentVarGuard() as env:
env['PYTHON_COLORS'] = '0'
return func(*args, **kwargs)
return inner


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

@no_color
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 +111,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 +126,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 +136,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 +147,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'')

@no_color
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'')

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

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

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

@no_color
def test_compact(self):
input_ = '[ 1 ,\n 2]'
expect = '[1,2]\n'
Expand All @@ -203,7 +223,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,14 +235,15 @@ 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)

@unittest.skipIf(sys.platform =="win32", "The test is failed with ValueError on Windows")
@no_color
def test_broken_pipe_error(self):
cmd = [sys.executable, '-m', self.module]
proc = subprocess.Popen(cmd,
Expand All @@ -232,6 +254,53 @@ 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[36mnull\x1b[0m'),
('true', b'\x1b[36mtrue\x1b[0m'),
('false', b'\x1b[36mfalse\x1b[0m'),
('"foo"', b'\x1b[32m"foo"\x1b[0m'),
(r'" \"foo\" "', b'\x1b[32m" \\"foo\\" "\x1b[0m'),
('123', b'\x1b[33m123\x1b[0m'),
('-1.2345e+23', b'\x1b[33m-1.2345e+23\x1b[0m'),
(r'{"\\": ""}',
b'''\
{
\x1b[32m"\\\\"\x1b[0m: \x1b[32m""\x1b[0m
}'''),
(r'{"\\\\": ""}',
b'''\
{
\x1b[32m"\\\\\\\\"\x1b[0m: \x1b[32m""\x1b[0m
}'''),
('{"foo": "bar", "baz": 1234, "qux": [true, false, null]}',
b'''\
{
\x1b[32m"foo"\x1b[0m: \x1b[32m"bar"\x1b[0m,
\x1b[32m"baz"\x1b[0m: \x1b[33m1234\x1b[0m,
\x1b[32m"qux"\x1b[0m: [
\x1b[36mtrue\x1b[0m,
\x1b[36mfalse\x1b[0m,
\x1b[36mnull\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):
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add color output to the :program:`json.tool` CLI.
Loading