From cae293fa94ed9246604517f4b566fa5169644a28 Mon Sep 17 00:00:00 2001 From: Tomas Roun Date: Sat, 5 Apr 2025 15:14:03 +0200 Subject: [PATCH 01/23] Add color to the json.tool CLI output --- Lib/json/tool.py | 36 ++++++++++++++++++++++++++++++++- Lib/test/test_json/test_tool.py | 34 +++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/Lib/json/tool.py b/Lib/json/tool.py index 1ba91384c81f27..40c3a1e1b1b2b6 100644 --- a/Lib/json/tool.py +++ b/Lib/json/tool.py @@ -6,6 +6,8 @@ import argparse import json import sys +import re +from _colorize import ANSIColors, can_colorize def main(): @@ -48,6 +50,8 @@ def main(): dump_args['indent'] = None dump_args['separators'] = ',', ':' + with_colors = can_colorize() + try: if options.infile == '-': infile = sys.stdin @@ -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 + (?P[\d\-+.Ee]+) | # Number + (?Ptrue|false) | # Boolean + (?Pnull) # 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() diff --git a/Lib/test/test_json/test_tool.py b/Lib/test/test_json/test_tool.py index 5da7cdcad709fa..b1d36833e5478a 100644 --- a/Lib/test/test_json/test_tool.py +++ b/Lib/test/test_json/test_tool.py @@ -232,6 +232,40 @@ 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'), + ('123', b'\x1b[33m123\x1b[0m'), + ('-1.2345e+23', b'\x1b[33m-1.2345e+23\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') + self.assertEqual(stdout.strip(), expected) + @support.requires_subprocess() class TestTool(TestMain): From 1854ae50a66b49769f1b5222dfe39e19a7aec922 Mon Sep 17 00:00:00 2001 From: Tomas Roun Date: Sat, 5 Apr 2025 16:05:47 +0200 Subject: [PATCH 02/23] Add news entry --- .../next/Library/2025-04-05-16-05-34.gh-issue-131952.HX6gCX.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 Misc/NEWS.d/next/Library/2025-04-05-16-05-34.gh-issue-131952.HX6gCX.rst 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..f153f544dc4c62 --- /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.tool` CLI. From ee39cb2d22c85ddc0da3b73a535e40fe23f06c3f Mon Sep 17 00:00:00 2001 From: Tomas Roun Date: Sat, 5 Apr 2025 18:02:24 +0200 Subject: [PATCH 03/23] Fix escaped quotes --- Lib/json/tool.py | 8 ++++---- Lib/test/test_json/test_tool.py | 1 + 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/Lib/json/tool.py b/Lib/json/tool.py index 40c3a1e1b1b2b6..6dd6faa948c1d4 100644 --- a/Lib/json/tool.py +++ b/Lib/json/tool.py @@ -83,10 +83,10 @@ def main(): color_pattern = re.compile(r''' - (?P"(.*?)") | # String - (?P[\d\-+.Ee]+) | # Number - (?Ptrue|false) | # Boolean - (?Pnull) # Null + (?P"(\\"|[^"])*?") | # String + (?P[\d\-+.Ee]+) | # Number + (?Ptrue|false) | # Boolean + (?Pnull) # Null ''', re.VERBOSE) diff --git a/Lib/test/test_json/test_tool.py b/Lib/test/test_json/test_tool.py index b1d36833e5478a..d4f8a4c49c6ab7 100644 --- a/Lib/test/test_json/test_tool.py +++ b/Lib/test/test_json/test_tool.py @@ -243,6 +243,7 @@ def test_colors(self): ('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'), ('{"foo": "bar", "baz": 1234, "qux": [true, false, null]}', From 5e726eede377262fa1f0c926e2db57a3e817486b Mon Sep 17 00:00:00 2001 From: Tomas Roun Date: Sat, 5 Apr 2025 18:59:08 +0200 Subject: [PATCH 04/23] Fix tests --- Lib/test/test_json/test_tool.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/Lib/test/test_json/test_tool.py b/Lib/test/test_json/test_tool.py index d4f8a4c49c6ab7..e9b250dff987a5 100644 --- a/Lib/test/test_json/test_tool.py +++ b/Lib/test/test_json/test_tool.py @@ -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 = """ @@ -87,12 +95,14 @@ 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) self.assertEqual(process.stdout, self.expect) self.assertEqual(process.stderr, '') + @no_color def _create_infile(self, data=None): infile = os_helper.TESTFN with open(infile, "w", encoding="utf-8") as fp: @@ -100,6 +110,7 @@ def _create_infile(self, data=None): fp.write(data or self.data) return infile + @no_color def test_infile_stdout(self): infile = self._create_infile() rc, out, err = assert_python_ok('-m', self.module, infile) @@ -107,6 +118,7 @@ def test_infile_stdout(self): self.assertEqual(out.splitlines(), self.expect.encode().splitlines()) self.assertEqual(err, b'') + @no_color def test_non_ascii_infile(self): data = '{"msg": "\u3053\u3093\u306b\u3061\u306f"}' expect = textwrap.dedent('''\ @@ -122,6 +134,7 @@ def test_non_ascii_infile(self): self.assertEqual(out.splitlines(), expect.splitlines()) self.assertEqual(err, b'') + @no_color def test_infile_outfile(self): infile = self._create_infile() outfile = os_helper.TESTFN + '.out' @@ -133,6 +146,7 @@ def test_infile_outfile(self): self.assertEqual(out, b'') self.assertEqual(err, b'') + @no_color def test_writing_in_place(self): infile = self._create_infile() rc, out, err = assert_python_ok('-m', self.module, infile, infile) @@ -142,18 +156,21 @@ def test_writing_in_place(self): 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, '') + @no_color def test_help_flag(self): rc, out, err = assert_python_ok('-m', self.module, '-h') self.assertEqual(rc, 0) self.assertTrue(out.startswith(b'usage: ')) self.assertEqual(err, b'') + @no_color def test_sort_keys_flag(self): infile = self._create_infile() rc, out, err = assert_python_ok('-m', self.module, '--sort-keys', infile) @@ -162,6 +179,7 @@ def test_sort_keys_flag(self): self.expect_without_sort_keys.encode().splitlines()) self.assertEqual(err, b'') + @no_color def test_indent(self): input_ = '[1, 2]' expect = textwrap.dedent('''\ @@ -175,6 +193,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' @@ -183,6 +202,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' @@ -191,6 +211,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' @@ -199,6 +220,7 @@ def test_compact(self): self.assertEqual(process.stdout, expect) self.assertEqual(process.stderr, '') + @no_color def test_no_ensure_ascii_flag(self): infile = self._create_infile('{"key":"💩"}') outfile = os_helper.TESTFN + '.out' @@ -210,6 +232,7 @@ def test_no_ensure_ascii_flag(self): expected = [b'{', b' "key": "\xf0\x9f\x92\xa9"', b"}"] self.assertEqual(lines, expected) + @no_color def test_ensure_ascii_default(self): infile = self._create_infile('{"key":"💩"}') outfile = os_helper.TESTFN + '.out' @@ -222,6 +245,7 @@ def test_ensure_ascii_default(self): 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, From 848a7becfdf0cff696cdf7698c991909e91ff049 Mon Sep 17 00:00:00 2001 From: Tomas Roun Date: Sat, 5 Apr 2025 19:10:44 +0200 Subject: [PATCH 05/23] Fix the tests for real this time --- Lib/test/test_json/test_tool.py | 32 +++++++++++++++----------------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/Lib/test/test_json/test_tool.py b/Lib/test/test_json/test_tool.py index e9b250dff987a5..e6fbf035474e30 100644 --- a/Lib/test/test_json/test_tool.py +++ b/Lib/test/test_json/test_tool.py @@ -102,7 +102,6 @@ def test_stdin_stdout(self): self.assertEqual(process.stdout, self.expect) self.assertEqual(process.stderr, '') - @no_color def _create_infile(self, data=None): infile = os_helper.TESTFN with open(infile, "w", encoding="utf-8") as fp: @@ -110,15 +109,14 @@ def _create_infile(self, data=None): fp.write(data or self.data) return infile - @no_color 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'') - @no_color def test_non_ascii_infile(self): data = '{"msg": "\u3053\u3093\u306b\u3061\u306f"}' expect = textwrap.dedent('''\ @@ -128,17 +126,18 @@ 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()) self.assertEqual(err, b'') - @no_color 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) @@ -146,10 +145,10 @@ def test_infile_outfile(self): self.assertEqual(out, b'') self.assertEqual(err, b'') - @no_color 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) @@ -163,17 +162,17 @@ def test_jsonlines(self): self.assertEqual(process.stdout, self.jsonlines_expect) self.assertEqual(process.stderr, '') - @no_color 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'') - @no_color 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()) @@ -220,24 +219,23 @@ def test_compact(self): self.assertEqual(process.stdout, expect) self.assertEqual(process.stderr, '') - @no_color 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 expected = [b'{', b' "key": "\xf0\x9f\x92\xa9"', b"}"] self.assertEqual(lines, expected) - @no_color 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 From 8d90ccb5c23aad60027c08e274bb6aa6addc2ae5 Mon Sep 17 00:00:00 2001 From: Tomas Roun Date: Sat, 5 Apr 2025 21:07:14 +0200 Subject: [PATCH 06/23] Fix tests on Windows --- Lib/test/test_json/test_tool.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Lib/test/test_json/test_tool.py b/Lib/test/test_json/test_tool.py index e6fbf035474e30..dc8aeb7ba59566 100644 --- a/Lib/test/test_json/test_tool.py +++ b/Lib/test/test_json/test_tool.py @@ -287,7 +287,9 @@ def test_colors(self): fp.write(input_) _, stdout, _ = assert_python_ok('-m', self.module, infile, PYTHON_COLORS='1') - self.assertEqual(stdout.strip(), expected) + stdout = stdout.replace(b'\r\n', b'\n') # normalize line endings + stdout = stdout.strip() + self.assertEqual(stdout, expected) @support.requires_subprocess() From 93e430611c123883675dc09572b20df55c5b3dd6 Mon Sep 17 00:00:00 2001 From: Tomas Roun Date: Sat, 5 Apr 2025 21:12:02 +0200 Subject: [PATCH 07/23] Sort imports --- Lib/json/tool.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/json/tool.py b/Lib/json/tool.py index 6dd6faa948c1d4..1e613d535a0a85 100644 --- a/Lib/json/tool.py +++ b/Lib/json/tool.py @@ -5,8 +5,8 @@ """ import argparse import json -import sys import re +import sys from _colorize import ANSIColors, can_colorize From f8d697a1d05e630085ac60e95554d64966b23bce Mon Sep 17 00:00:00 2001 From: Tomas Roun Date: Sat, 5 Apr 2025 23:19:32 +0200 Subject: [PATCH 08/23] Fix string regex --- Lib/json/tool.py | 8 ++++---- Lib/test/test_json/test_tool.py | 10 ++++++++++ 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/Lib/json/tool.py b/Lib/json/tool.py index 1e613d535a0a85..64b3af69daf7ba 100644 --- a/Lib/json/tool.py +++ b/Lib/json/tool.py @@ -83,10 +83,10 @@ def main(): color_pattern = re.compile(r''' - (?P"(\\"|[^"])*?") | # String - (?P[\d\-+.Ee]+) | # Number - (?Ptrue|false) | # Boolean - (?Pnull) # Null + (?P"(\\.|[^"\\])*?") | # String + (?P[\d\-+.Ee]+) | # Number + (?Ptrue|false) | # Boolean + (?Pnull) # Null ''', re.VERBOSE) diff --git a/Lib/test/test_json/test_tool.py b/Lib/test/test_json/test_tool.py index dc8aeb7ba59566..3473e5191d8902 100644 --- a/Lib/test/test_json/test_tool.py +++ b/Lib/test/test_json/test_tool.py @@ -268,6 +268,16 @@ def test_colors(self): (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'''\ { From 429e350bb4e183af787843b2b697f84ae6bb781a Mon Sep 17 00:00:00 2001 From: Tomas Roun Date: Sun, 6 Apr 2025 10:03:59 +0200 Subject: [PATCH 09/23] Handle NaN & Infinity --- Lib/json/tool.py | 8 ++++---- Lib/test/test_json/test_tool.py | 16 +++++++++++++++- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/Lib/json/tool.py b/Lib/json/tool.py index 64b3af69daf7ba..ca3e3bd916dcbf 100644 --- a/Lib/json/tool.py +++ b/Lib/json/tool.py @@ -83,10 +83,10 @@ def main(): color_pattern = re.compile(r''' - (?P"(\\.|[^"\\])*?") | # String - (?P[\d\-+.Ee]+) | # Number - (?Ptrue|false) | # Boolean - (?Pnull) # Null + (?P"(\\.|[^"\\])*?") | # String + (?PNaN|-?Infinity|[\d\-+.Ee]+) | # Number + (?Ptrue|false) | # Boolean + (?Pnull) # Null ''', re.VERBOSE) diff --git a/Lib/test/test_json/test_tool.py b/Lib/test/test_json/test_tool.py index 3473e5191d8902..d54c9ed5811077 100644 --- a/Lib/test/test_json/test_tool.py +++ b/Lib/test/test_json/test_tool.py @@ -264,6 +264,9 @@ def test_colors(self): ('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'), ('123', b'\x1b[33m123\x1b[0m'), @@ -278,7 +281,13 @@ def test_colors(self): { \x1b[32m"\\\\\\\\"\x1b[0m: \x1b[32m""\x1b[0m }'''), - ('{"foo": "bar", "baz": 1234, "qux": [true, false, null]}', + ('''\ +{ + "foo": "bar", + "baz": 1234, + "qux": [true, false, null], + "xyz": [NaN, -Infinity, Infinity] +}''', b'''\ { \x1b[32m"foo"\x1b[0m: \x1b[32m"bar"\x1b[0m, @@ -287,6 +296,11 @@ def test_colors(self): \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 ] }'''), ) From 0abe76a62c0333ad9d4277fa88224b64614e3439 Mon Sep 17 00:00:00 2001 From: Tomas Roun Date: Sun, 6 Apr 2025 11:22:42 +0200 Subject: [PATCH 10/23] Use only digits in number regex --- Lib/json/tool.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Lib/json/tool.py b/Lib/json/tool.py index ca3e3bd916dcbf..0bda807b55b2d4 100644 --- a/Lib/json/tool.py +++ b/Lib/json/tool.py @@ -83,10 +83,10 @@ def main(): color_pattern = re.compile(r''' - (?P"(\\.|[^"\\])*?") | # String - (?PNaN|-?Infinity|[\d\-+.Ee]+) | # Number - (?Ptrue|false) | # Boolean - (?Pnull) # Null + (?P"(\\.|[^"\\])*?") | # String + (?PNaN|-?Infinity|[0-9\-+.Ee]+) | # Number + (?Ptrue|false) | # Boolean + (?Pnull) # Null ''', re.VERBOSE) From 1acd35ddb7165e0b83a1c0c18dffef9116e5986e Mon Sep 17 00:00:00 2001 From: Tomas Roun Date: Sun, 6 Apr 2025 11:24:48 +0200 Subject: [PATCH 11/23] Remove non-greedy matching in string regex --- Lib/json/tool.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/json/tool.py b/Lib/json/tool.py index 0bda807b55b2d4..be903e40b898fa 100644 --- a/Lib/json/tool.py +++ b/Lib/json/tool.py @@ -83,7 +83,7 @@ def main(): color_pattern = re.compile(r''' - (?P"(\\.|[^"\\])*?") | # String + (?P"(\\.|[^"\\])*") | # String (?PNaN|-?Infinity|[0-9\-+.Ee]+) | # Number (?Ptrue|false) | # Boolean (?Pnull) # Null From 691aecd3cf1af5feec6e772bf2b1482b1d222ebe Mon Sep 17 00:00:00 2001 From: Tomas Roun Date: Sun, 6 Apr 2025 11:41:50 +0200 Subject: [PATCH 12/23] Test unicode --- Lib/test/test_json/test_tool.py | 1 + 1 file changed, 1 insertion(+) diff --git a/Lib/test/test_json/test_tool.py b/Lib/test/test_json/test_tool.py index d54c9ed5811077..1452d08c233f2c 100644 --- a/Lib/test/test_json/test_tool.py +++ b/Lib/test/test_json/test_tool.py @@ -269,6 +269,7 @@ def test_colors(self): ('-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'{"\\": ""}', From 5afac978379965dfa1f454d449dff5968ad1b48b Mon Sep 17 00:00:00 2001 From: "Tomas R." Date: Sun, 6 Apr 2025 19:02:59 +0200 Subject: [PATCH 13/23] Pass the file to `can_colorize` Co-authored-by: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> --- Lib/json/tool.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/json/tool.py b/Lib/json/tool.py index be903e40b898fa..d0dee0394b774c 100644 --- a/Lib/json/tool.py +++ b/Lib/json/tool.py @@ -72,7 +72,7 @@ def main(): outfile = open(options.outfile, 'w', encoding='utf-8') with outfile: for obj in objs: - if with_colors: + if can_colorize(file=outfile): json_str = json.dumps(obj, **dump_args) outfile.write(colorize_json(json_str)) else: From 911f75fd8a64d52d3a3928d9cc138f8621fc30a5 Mon Sep 17 00:00:00 2001 From: Tomas Roun Date: Sun, 6 Apr 2025 19:04:08 +0200 Subject: [PATCH 14/23] Make the test file runnable --- Lib/test/test_json/test_tool.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Lib/test/test_json/test_tool.py b/Lib/test/test_json/test_tool.py index 1452d08c233f2c..4750f2fa191050 100644 --- a/Lib/test/test_json/test_tool.py +++ b/Lib/test/test_json/test_tool.py @@ -320,3 +320,7 @@ def test_colors(self): @support.requires_subprocess() class TestTool(TestMain): module = 'json.tool' + + +if __name__ == "__main__": + unittest.main() From 4c27be77333429022b2e723fc2023fd6c82f62e1 Mon Sep 17 00:00:00 2001 From: Tomas Roun Date: Sun, 6 Apr 2025 19:06:24 +0200 Subject: [PATCH 15/23] Use force_not_colorized --- Lib/test/test_json/test_tool.py | 24 ++++++++---------------- 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/Lib/test/test_json/test_tool.py b/Lib/test/test_json/test_tool.py index 4750f2fa191050..d000382d01b184 100644 --- a/Lib/test/test_json/test_tool.py +++ b/Lib/test/test_json/test_tool.py @@ -6,18 +6,10 @@ 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 -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 = """ @@ -95,7 +87,7 @@ class TestMain(unittest.TestCase): } """) - @no_color + @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) @@ -155,7 +147,7 @@ def test_writing_in_place(self): self.assertEqual(out, b'') self.assertEqual(err, b'') - @no_color + @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) @@ -178,7 +170,7 @@ def test_sort_keys_flag(self): self.expect_without_sort_keys.encode().splitlines()) self.assertEqual(err, b'') - @no_color + @force_not_colorized def test_indent(self): input_ = '[1, 2]' expect = textwrap.dedent('''\ @@ -192,7 +184,7 @@ def test_indent(self): self.assertEqual(process.stdout, expect) self.assertEqual(process.stderr, '') - @no_color + @force_not_colorized def test_no_indent(self): input_ = '[1,\n2]' expect = '[1, 2]\n' @@ -201,7 +193,7 @@ def test_no_indent(self): self.assertEqual(process.stdout, expect) self.assertEqual(process.stderr, '') - @no_color + @force_not_colorized def test_tab(self): input_ = '[1, 2]' expect = '[\n\t1,\n\t2\n]\n' @@ -210,7 +202,7 @@ def test_tab(self): self.assertEqual(process.stdout, expect) self.assertEqual(process.stderr, '') - @no_color + @force_not_colorized def test_compact(self): input_ = '[ 1 ,\n 2]' expect = '[1,2]\n' @@ -242,8 +234,8 @@ def test_ensure_ascii_default(self): 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") - @no_color def test_broken_pipe_error(self): cmd = [sys.executable, '-m', self.module] proc = subprocess.Popen(cmd, From 18ce6fac9cf3f140d9179364261a07c047b98a2e Mon Sep 17 00:00:00 2001 From: Tomas Roun Date: Sun, 6 Apr 2025 19:08:06 +0200 Subject: [PATCH 16/23] Remove unused variable --- Lib/json/tool.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/Lib/json/tool.py b/Lib/json/tool.py index d0dee0394b774c..ddd005bb438eda 100644 --- a/Lib/json/tool.py +++ b/Lib/json/tool.py @@ -50,8 +50,6 @@ def main(): dump_args['indent'] = None dump_args['separators'] = ',', ':' - with_colors = can_colorize() - try: if options.infile == '-': infile = sys.stdin From 18f7ae596e9076d3abd8b2ed7daebda3ce2b46e2 Mon Sep 17 00:00:00 2001 From: "Tomas R." Date: Sun, 6 Apr 2025 19:45:15 +0200 Subject: [PATCH 17/23] =?UTF-8?q?=F0=9F=A6=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Bénédikt Tran <10796600+picnixz@users.noreply.github.com> --- Lib/json/tool.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Lib/json/tool.py b/Lib/json/tool.py index ddd005bb438eda..df7858394728ba 100644 --- a/Lib/json/tool.py +++ b/Lib/json/tool.py @@ -98,9 +98,9 @@ def colorize_json(json_str): def replace(match): for key in colors: - if match.group(key): + if m := match.group(key): color = colors[key] - return f"{color}{match.group(key)}{ANSIColors.RESET}" + return f"{color}{m}{ANSIColors.RESET}" return match.group() return re.sub(color_pattern, replace, json_str) From b5157af6eb8822d3c9daa6d13c2670d6af12dcc1 Mon Sep 17 00:00:00 2001 From: Tomas Roun Date: Sun, 6 Apr 2025 19:50:19 +0200 Subject: [PATCH 18/23] Add a comment to the color regex --- Lib/json/tool.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Lib/json/tool.py b/Lib/json/tool.py index df7858394728ba..94d9e8c7ff9886 100644 --- a/Lib/json/tool.py +++ b/Lib/json/tool.py @@ -80,6 +80,10 @@ def main(): raise SystemExit(e) +# 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 (?PNaN|-?Infinity|[0-9\-+.Ee]+) | # Number From d7287e208e5da8e7d12d10152caa17076df74110 Mon Sep 17 00:00:00 2001 From: Tomas Roun Date: Sun, 6 Apr 2025 19:50:56 +0200 Subject: [PATCH 19/23] Move helper functions to the start --- Lib/json/tool.py | 60 ++++++++++++++++++++++++------------------------ 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/Lib/json/tool.py b/Lib/json/tool.py index 94d9e8c7ff9886..96c0356515d057 100644 --- a/Lib/json/tool.py +++ b/Lib/json/tool.py @@ -10,6 +10,36 @@ 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 + (?PNaN|-?Infinity|[0-9\-+.Ee]+) | # Number + (?Ptrue|false) | # Boolean + (?Pnull) # 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 m := match.group(key): + color = colors[key] + return f"{color}{m}{ANSIColors.RESET}" + return match.group() + + return re.sub(color_pattern, replace, json_str) + + def main(): description = ('A simple command line interface for json module ' 'to validate and pretty-print JSON objects.') @@ -80,36 +110,6 @@ def main(): raise SystemExit(e) -# 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 - (?PNaN|-?Infinity|[0-9\-+.Ee]+) | # Number - (?Ptrue|false) | # Boolean - (?Pnull) # 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 m := match.group(key): - color = colors[key] - return f"{color}{m}{ANSIColors.RESET}" - return match.group() - - return re.sub(color_pattern, replace, json_str) - - if __name__ == '__main__': try: main() From 177107b90493af31d891438fd19e455c1bb1597a Mon Sep 17 00:00:00 2001 From: Tomas Roun Date: Sun, 6 Apr 2025 19:51:39 +0200 Subject: [PATCH 20/23] Make helper functions private --- Lib/json/tool.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Lib/json/tool.py b/Lib/json/tool.py index 96c0356515d057..91c1f839097e50 100644 --- a/Lib/json/tool.py +++ b/Lib/json/tool.py @@ -14,7 +14,7 @@ # 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''' +_color_pattern = re.compile(r''' (?P"(\\.|[^"\\])*") | # String (?PNaN|-?Infinity|[0-9\-+.Ee]+) | # Number (?Ptrue|false) | # Boolean @@ -22,7 +22,7 @@ ''', re.VERBOSE) -def colorize_json(json_str): +def _colorize_json(json_str): colors = { 'string': ANSIColors.GREEN, 'number': ANSIColors.YELLOW, @@ -37,7 +37,7 @@ def replace(match): return f"{color}{m}{ANSIColors.RESET}" return match.group() - return re.sub(color_pattern, replace, json_str) + return re.sub(_color_pattern, replace, json_str) def main(): @@ -102,7 +102,7 @@ def main(): for obj in objs: if can_colorize(file=outfile): json_str = json.dumps(obj, **dump_args) - outfile.write(colorize_json(json_str)) + outfile.write(_colorize_json(json_str)) else: json.dump(obj, outfile, **dump_args) outfile.write('\n') From b7589be40bdc76aa72754ab60cd7a5c1dae9db7e Mon Sep 17 00:00:00 2001 From: Tomas Roun Date: Sun, 6 Apr 2025 20:05:21 +0200 Subject: [PATCH 21/23] Prefer global functions --- Lib/json/tool.py | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/Lib/json/tool.py b/Lib/json/tool.py index 91c1f839097e50..1e167931fb99f4 100644 --- a/Lib/json/tool.py +++ b/Lib/json/tool.py @@ -22,22 +22,24 @@ ''', re.VERBOSE) -def _colorize_json(json_str): - colors = { - 'string': ANSIColors.GREEN, - 'number': ANSIColors.YELLOW, - 'boolean': ANSIColors.CYAN, - 'null': ANSIColors.CYAN, - } +_colors = { + 'string': ANSIColors.GREEN, + 'number': ANSIColors.YELLOW, + 'boolean': ANSIColors.CYAN, + 'null': ANSIColors.CYAN, +} + - def replace(match): - for key in colors: - if m := match.group(key): - color = colors[key] - return f"{color}{m}{ANSIColors.RESET}" - return match.group() +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() - return re.sub(_color_pattern, replace, json_str) + +def _colorize_json(json_str): + return re.sub(_color_pattern, _replace_match_callback, json_str) def main(): From e744290f0bd06178b8fb1738cd404e405ffa4d0b Mon Sep 17 00:00:00 2001 From: Tomas Roun Date: Sun, 6 Apr 2025 20:42:56 +0200 Subject: [PATCH 22/23] Remove redundant comments --- Lib/json/tool.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Lib/json/tool.py b/Lib/json/tool.py index 1e167931fb99f4..e291b4e41e7156 100644 --- a/Lib/json/tool.py +++ b/Lib/json/tool.py @@ -15,10 +15,10 @@ # 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 - (?PNaN|-?Infinity|[0-9\-+.Ee]+) | # Number - (?Ptrue|false) | # Boolean - (?Pnull) # Null + (?P"(\\.|[^"\\])*") | + (?PNaN|-?Infinity|[0-9\-+.Ee]+) | + (?Ptrue|false) | + (?Pnull) ''', re.VERBOSE) From fac39c7cb6c0c89a49f9a00708d4732de6a11185 Mon Sep 17 00:00:00 2001 From: "Tomas R." Date: Sun, 6 Apr 2025 21:09:23 +0200 Subject: [PATCH 23/23] Improve news entry Co-authored-by: Adam Turner <9087854+AA-Turner@users.noreply.github.com> --- .../next/Library/2025-04-05-16-05-34.gh-issue-131952.HX6gCX.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index f153f544dc4c62..fa803075ec3012 100644 --- 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 @@ -1 +1 @@ -Add color output to the :program:`json.tool` CLI. +Add color output to the :program:`json` CLI.