https://github.com/python/cpython/commit/71da68d5887b6c05890757d676b452ede3120617 commit: 71da68d5887b6c05890757d676b452ede3120617 branch: main author: Tomas R. <tomas.ro...@gmail.com> committer: AA-Turner <9087854+aa-tur...@users.noreply.github.com> date: 2025-04-19T18:11:21Z summary:
gh-131952: Add colour to the ``json`` module CLI (#132126) Co-authored-by: Adam Turner <9087854+aa-tur...@users.noreply.github.com> Co-authored-by: Bénédikt Tran <10796600+picn...@users.noreply.github.com> Co-authored-by: Hugo van Kemenade <1324225+hug...@users.noreply.github.com> files: A Misc/NEWS.d/next/Library/2025-04-05-16-05-34.gh-issue-131952.HX6gCX.rst M Doc/whatsnew/3.14.rst M Lib/json/tool.py M Lib/test/test_json/test_tool.py diff --git a/Doc/whatsnew/3.14.rst b/Doc/whatsnew/3.14.rst index 56858aee4493a3..f02ce6bc1d4f2f 100644 --- a/Doc/whatsnew/3.14.rst +++ b/Doc/whatsnew/3.14.rst @@ -825,6 +825,12 @@ json See the :ref:`JSON command-line interface <json-commandline>` documentation. (Contributed by Trey Hunner in :gh:`122873`.) +* By default, the output of the :ref:`JSON command-line interface <json-commandline>` + is highlighted in color. This can be controlled via the + :envvar:`PYTHON_COLORS` environment variable as well as the canonical + |NO_COLOR|_ and |FORCE_COLOR|_ environment variables. See also + :ref:`using-on-controlling-color`. + (Contributed by Tomas Roun in :gh:`131952`.) linecache --------- diff --git a/Lib/json/tool.py b/Lib/json/tool.py index 1ba91384c81f27..585583da8604ac 100644 --- a/Lib/json/tool.py +++ b/Lib/json/tool.py @@ -5,7 +5,40 @@ """ import argparse import json +import re import sys +from _colorize import ANSIColors, can_colorize + + +# The string we are colorizing is 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<boolean>true|false) | + (?P<null>null) +''', re.VERBOSE) + + +_colors = { + 'key': ANSIColors.INTENSE_BLUE, + 'string': ANSIColors.BOLD_GREEN, + 'boolean': ANSIColors.BOLD_CYAN, + 'null': ANSIColors.BOLD_CYAN, +} + + +def _replace_match_callback(match): + for key, color in _colors.items(): + if m := match.group(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(): @@ -68,7 +101,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) diff --git a/Lib/test/test_json/test_tool.py b/Lib/test/test_json/test_tool.py index 5da7cdcad709fa..ba9c42f758e2b2 100644 --- a/Lib/test/test_json/test_tool.py +++ b/Lib/test/test_json/test_tool.py @@ -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 @@ -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) @@ -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'') @@ -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()) @@ -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) @@ -135,13 +139,15 @@ 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) @@ -149,19 +155,22 @@ def test_jsonlines(self): 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('''\ @@ -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' @@ -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' @@ -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' @@ -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 @@ -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] @@ -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'NaN'), + ('Infinity', b'Infinity'), + ('-Infinity', b'-Infinity'), + ('"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'123'), + ('-1.2345e+23', b'-1.2345e+23'), + (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: 1234, + \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: [ + NaN, + -Infinity, + Infinity + ] +}'''), + ) + + 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() diff --git a/Misc/NEWS.d/next/Library/2025-04-05-16-05-34.gh-issue-131952.HX6gCX.rst b/Misc/NEWS.d/next/Library/2025-04-05-16-05-34.gh-issue-131952.HX6gCX.rst new file mode 100644 index 00000000000000..4679abf105d0ea --- /dev/null +++ b/Misc/NEWS.d/next/Library/2025-04-05-16-05-34.gh-issue-131952.HX6gCX.rst @@ -0,0 +1 @@ +Add color output to the :program:`json` CLI. Patch by Tomas Roun. _______________________________________________ Python-checkins mailing list -- python-checkins@python.org To unsubscribe send an email to python-checkins-le...@python.org https://mail.python.org/mailman3/lists/python-checkins.python.org/ Member address: arch...@mail-archive.com