8000 gh-131952: Add color to the `json` CLI by tomasr8 · Pull Request #132126 · python/cpython · GitHub
[go: up one dir, main page]

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 8 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
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()

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This variable isn't doing much, shall we just call the function directly?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I may have been too cautious here. Without the variable, the function would be called in a loop and I was worried the result could change in between invocations, though that is really unlikely. If you prefer I can remove it?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, I applied your other suggestion which already removed it so never mind 😄

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, that's a good point: there could be a lot of objs as JSONL files can be quite long. Let's add with_colors back, but just outside the loop.

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
0