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

Reply via email to