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 all 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
40 changes: 39 additions & 1 deletion Lib/json/tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,41 @@
"""
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<string>"(\\.|[^"\\])*") |
(?P<number>NaN|-?Infinity|[0-9\-+.Ee]+) |
(?P<boolean>true|false) |
(?P<null>null)
''', re.VERBOSE)


_colors = {
'string': ANSIColors.GREEN,
'number': ANSIColors.YELLOW,
'boolean': ANSIColors.CYAN,
'null': ANSIColors.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 +102,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[36mnull\x1b[0m'),
('true', b'\x1b[36mtrue\x1b[0m'),
('false', b'\x1b[36mfalse\x1b[0m'),
('NaN', b'\x1b[33mNaN\x1b[0m'),
('Infinity', b'\x1b[33mInfinity\x1b[0m'),
('-Infinity', b'\x1b[33m-Infinity\x1b[0m'),
('"foo"', b'\x1b[32m"foo"\x1b[0m'),
(r'" \"foo\" "', b'\x1b[32m" \\"foo\\" "\x1b[0m'),
('"α"', b'\x1b[32m"\\u03b1"\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],
"xyz": [NaN, -Infinity, Infinity]
}''',
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
],
\x1b[32m"xyz"\x1b[0m: [
\x1b[33mNaN\x1b[0m,
\x1b[33m-Infinity\x1b[0m,
\x1b[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