From 0de0198144eb0b30d5506d7968773908b2fa5259 Mon Sep 17 00:00:00 2001
From: Jelte Fennema-Nio <postgres@jeltef.nl>
Date: Mon, 27 Jul 2026 00:11:59 +0200
Subject: [PATCH v1 07/12] POC: test_json_parser: port TAP test 002_inline to
 pytest
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Test runtime on Windows improves a lot for this test in CI:

    platform    perl             pytest           diff
    ----------- ---------------- ---------------- ----------------
    windows      14.4s (±0.4)      2.9s (±0.2)     -11.5s (-80%)
    mingw         3.2s (±0.1)      2.1s (±0.1)      -1.1s (-35%)
    linux-64      4.0s (±0.0)      3.6s (±0.1)      -0.4s (-10%)
    linux-32      1.2s (±0.0)      0.9s (±0.0)      -0.3s (-26%)
    macos         2.5s (±0.2)      2.5s (±0.1)      -0.0s (-0%)

Timings are means of 5 runs of each form, interleaved on one CI runner with
nothing else running on it; ± is the standard deviation across the 5 runs.

LOC (no comments or blanks, with tokei): 135 -> 135 (+0%).
---
 src/test/modules/test_json_parser/Makefile    |   1 +
 src/test/modules/test_json_parser/meson.build |  10 +-
 .../test_json_parser/pyt/test_002_inline.py   | 159 +++++++++++++++++
 .../modules/test_json_parser/t/002_inline.pl  | 168 ------------------
 4 files changed, 169 insertions(+), 169 deletions(-)
 create mode 100644 src/test/modules/test_json_parser/pyt/test_002_inline.py
 delete mode 100644 src/test/modules/test_json_parser/t/002_inline.pl

diff --git a/src/test/modules/test_json_parser/Makefile b/src/test/modules/test_json_parser/Makefile
index af3f19424ed..33d3a42c7f3 100644
--- a/src/test/modules/test_json_parser/Makefile
+++ b/src/test/modules/test_json_parser/Makefile
@@ -3,6 +3,7 @@ PGFILEDESC = "standalone json parser tester"
 PGAPPICON = win32
 
 TAP_TESTS = 1
+PYTEST_TESTS = 1
 
 OBJS = test_json_parser_incremental.o test_json_parser_perf.o $(WIN32RES)
 
diff --git a/src/test/modules/test_json_parser/meson.build b/src/test/modules/test_json_parser/meson.build
index 2688686e37b..854510dde63 100644
--- a/src/test/modules/test_json_parser/meson.build
+++ b/src/test/modules/test_json_parser/meson.build
@@ -54,10 +54,18 @@ tests += {
   'name': 'test_json_parser',
   'sd': meson.current_source_dir(),
   'bd': meson.current_build_dir(),
+  'pytest': {
+    'tests': [
+      'pyt/test_002_inline.py',
+    ],
+    'deps': [
+      test_json_parser_incremental,
+      test_json_parser_incremental_shlib,
+    ],
+  },
   'tap': {
     'tests': [
       't/001_test_json_parser_incremental.pl',
-      't/002_inline.pl',
       't/003_test_semantic.pl',
       't/004_test_parser_perf.pl'
     ],
diff --git a/src/test/modules/test_json_parser/pyt/test_002_inline.py b/src/test/modules/test_json_parser/pyt/test_002_inline.py
new file mode 100644
index 00000000000..197b9318269
--- /dev/null
+++ b/src/test/modules/test_json_parser/pyt/test_002_inline.py
@@ -0,0 +1,159 @@
+# Copyright (c) 2021-2026, PostgreSQL Global Development Group
+
+"""Port of src/test/modules/test_json_parser/t/002_inline.pl.
+
+Test success or failure of the incremental (table-driven) JSON parser
+for a variety of small inputs.
+"""
+
+import re
+import subprocess
+
+import pytest
+from pypg.util import run
+
+EXES = [
+    ("test_json_parser_incremental",),
+    ("test_json_parser_incremental", "-o"),
+    ("test_json_parser_incremental_shlib",),
+    ("test_json_parser_incremental_shlib", "-o"),
+]
+
+# (name, input, error-regex-or-None). Inputs are bytes; backslash-heavy and
+# non-UTF-8 inputs are spelled out explicitly to avoid escaping ambiguity.
+CASES = [
+    ("number", b"12345", None),
+    ("string", b'"hello"', None),
+    ("false", b"false", None),
+    ("true", b"true", None),
+    ("null", b"null", None),
+    ("empty object", b"{}", None),
+    ("empty array", b"[]", None),
+    ("array with number", b"[12345]", None),
+    ("array with numbers", b"[12345,67890]", None),
+    ("array with null", b"[null]", None),
+    ("array with string", b'["hello"]', None),
+    ("array with boolean", b"[false]", None),
+    ("single pair", b'{"key": "value"}', None),
+    ("heavily nested array", b"[" * 3200 + b"]" * 3200, None),
+    ("serial escapes", b'"' + b"\\" * 8 + b'"', None),
+    (
+        "interrupted escapes",
+        b'"' + b"\\" * 3 + b'"' + b"\\" * 5 + b'"' + b"\\" * 2 + b'"',
+        None,
+    ),
+    ("whitespace", b'     ""     ', None),
+    ("unclosed empty object", b"{", r"input string ended unexpectedly"),
+    ("bad key", b"{{", r'Expected string or "}", but found "\{"'),
+    ("bad key", b"{{}", r'Expected string or "}", but found "\{"'),
+    ("numeric key", b"{1234: 2}", r'Expected string or "}", but found "1234"'),
+    (
+        "second numeric key",
+        b'{"a": "a", 1234: 2}',
+        r'Expected string, but found "1234"',
+    ),
+    (
+        "unclosed object with pair",
+        b'{"key": "value"',
+        r"input string ended unexpectedly",
+    ),
+    ("missing key value", b'{"key": }', r'Expected JSON value, but found "}"'),
+    ("missing colon", b'{"key" 12345}', r'Expected ":", but found "12345"'),
+    (
+        "missing comma",
+        b'{"key": 12345 12345}',
+        r'Expected "," or "}", but found "12345"',
+    ),
+    ("overnested array", b"[" * 6401, r"maximum permitted depth is 6400"),
+    ("overclosed array", b"[]]", r'Expected end of input, but found "]"'),
+    (
+        "unexpected token in array",
+        b"[ }}} ]",
+        r'Expected array element or "]", but found "}"',
+    ),
+    ("junk punctuation", b"[ ||| ]", r'Token "\|" is invalid'),
+    ("missing comma in array", b"[123 123]", r'Expected "," or "]", but found "123"'),
+    ("misspelled boolean", b"tru", r'Token "tru" is invalid'),
+    ("misspelled boolean in array", b"[tru]", r'Token "tru" is invalid'),
+    ("smashed top-level scalar", b"12zz", r'Token "12zz" is invalid'),
+    ("smashed scalar in array", b"[12zz]", r'Token "12zz" is invalid'),
+    (
+        "unknown escape sequence",
+        b'"hello\\vworld"',
+        r'Escape sequence "\\v" is invalid',
+    ),
+    (
+        "unescaped control",
+        b'"hello\tworld"',
+        r"Character with value 0x09 must be escaped",
+    ),
+    (
+        "incorrect escape count",
+        b'"' + b"\\" * 7 + b'"',
+        r'Token ""' + r"\\" * 7 + r'"" is invalid',
+    ),
+    # Case with three bytes: double-quote, backslash and <f5>.
+    # Both invalid-token and invalid-escape are possible errors, because for
+    # smaller chunk sizes the incremental parser skips the string parsing when
+    # it cannot find an ending quote.
+    (
+        "incomplete UTF-8 sequence",
+        b'"\\\xf5',
+        r'(Token|Escape sequence) ""?\\\xf5" is invalid',
+    ),
+]
+
+
+def split_nul(data):
+    """Mimic Perl's unpack("(Z*)*"): split on NUL into strings, dropping the
+    single trailing empty string left by a trailing NUL terminator.
+    """
+    parts = data.split(b"\0")
+    if parts and parts[-1] == b"":
+        parts.pop()
+    return [p.decode("latin-1") for p in parts]
+
+
+@pytest.mark.parametrize("exe", EXES, ids=[" ".join(e) for e in EXES])
+@pytest.mark.parametrize(
+    "data,error",
+    [(data, error) for _, data, error in CASES],
+    ids=[name for name, _, _ in CASES],
+)
+def test_inline(exe, data, error, tmp_path):
+    """Each input, against each program flavor, at every chunk size.
+
+    Parametrized rather than looped over so a failure names the input and the
+    remaining inputs still run; the case names become the test ids.
+    """
+    # Test the input with chunk sizes from max(input_size, 64) down to 1
+    chunk = min(len(data), 64)
+
+    fname = tmp_path / "input.json"
+    fname.write_bytes(data)
+
+    # The -r mode runs the parser in a loop, with output separated by nulls.
+    # Unpack that as a list of null-terminated ASCII strings (Z*) and check that
+    # each run produces the same result.
+    r = run(
+        *exe,
+        "-r",
+        chunk,
+        fname,
+        check=False,
+        stdout=subprocess.PIPE,
+        stderr=subprocess.PIPE,
+    )
+    stdout = split_nul(r.stdout)
+    stderr = split_nul(r.stderr)
+
+    assert len(stdout) == chunk, "stdout has correct number of entries"
+    assert len(stderr) == chunk, "stderr has correct number of entries"
+
+    for i, size in enumerate(range(chunk, 0, -1)):
+        if error is not None:
+            assert "SUCCESS" not in stdout[i], f"chunk size {size}: fails"
+            assert re.search(error, stderr[i]), f"chunk size {size}: {stderr[i]!r}"
+        else:
+            assert "SUCCESS" in stdout[i], f"chunk size {size}: succeeds"
+            assert stderr[i] == "", f"chunk size {size}: no error output"
diff --git a/src/test/modules/test_json_parser/t/002_inline.pl b/src/test/modules/test_json_parser/t/002_inline.pl
deleted file mode 100644
index 9813cf3f433..00000000000
--- a/src/test/modules/test_json_parser/t/002_inline.pl
+++ /dev/null
@@ -1,168 +0,0 @@
-
-# Copyright (c) 2021-2026, PostgreSQL Global Development Group
-
-# Test success or failure of the incremental (table-driven) JSON parser
-# for a variety of small inputs.
-
-use strict;
-use warnings FATAL => 'all';
-
-use PostgreSQL::Test::Utils;
-use Test::More;
-
-use File::Temp qw(tempfile);
-
-my $dir = PostgreSQL::Test::Utils::tempdir;
-my @exe;
-
-sub test
-{
-	local $Test::Builder::Level = $Test::Builder::Level + 1;
-
-	my ($name, $json, %params) = @_;
-	my $chunk = length($json);
-
-	# Test the input with chunk sizes from max(input_size, 64) down to 1
-
-	if ($chunk > 64)
-	{
-		$chunk = 64;
-	}
-
-	my ($fh, $fname) = tempfile(DIR => $dir);
-	print $fh "$json";
-	close($fh);
-
-	# The -r mode runs the parser in a loop, with output separated by nulls.
-	# Unpack that as a list of null-terminated ASCII strings (Z*) and check that
-	# each run produces the same result.
-	my ($all_stdout, $all_stderr) =
-	  run_command([ @exe, "-r", $chunk, $fname ]);
-
-	my @stdout = unpack("(Z*)*", $all_stdout);
-	my @stderr = unpack("(Z*)*", $all_stderr);
-
-	is(scalar @stdout, $chunk, "$name: stdout has correct number of entries");
-	is(scalar @stderr, $chunk, "$name: stderr has correct number of entries");
-
-	my $i = 0;
-
-	foreach my $size (reverse(1 .. $chunk))
-	{
-		if (defined($params{error}))
-		{
-			unlike($stdout[$i], qr/SUCCESS/,
-				"$name, chunk size $size: test fails");
-			like($stderr[$i], $params{error},
-				"$name, chunk size $size: correct error output");
-		}
-		else
-		{
-			like($stdout[$i], qr/SUCCESS/,
-				"$name, chunk size $size: test succeeds");
-			is($stderr[$i], "", "$name, chunk size $size: no error output");
-		}
-
-		$i++;
-	}
-}
-
-my @exes = (
-	[ "test_json_parser_incremental", ],
-	[ "test_json_parser_incremental", "-o", ],
-	[ "test_json_parser_incremental_shlib", ],
-	[ "test_json_parser_incremental_shlib", "-o", ]);
-
-foreach (@exes)
-{
-	@exe = @$_;
-	note "testing executable @exe";
-
-	test("number", "12345");
-	test("string", '"hello"');
-	test("false", "false");
-	test("true", "true");
-	test("null", "null");
-	test("empty object", "{}");
-	test("empty array", "[]");
-	test("array with number", "[12345]");
-	test("array with numbers", "[12345,67890]");
-	test("array with null", "[null]");
-	test("array with string", '["hello"]');
-	test("array with boolean", '[false]');
-	test("single pair", '{"key": "value"}');
-	test("heavily nested array", "[" x 3200 . "]" x 3200);
-	test("serial escapes", '"\\\\\\\\\\\\\\\\"');
-	test("interrupted escapes", '"\\\\\\"\\\\\\\\\\"\\\\"');
-	test("whitespace", '     ""     ');
-
-	test("unclosed empty object",
-		"{", error => qr/input string ended unexpectedly/);
-	test("bad key", "{{",
-		error => qr/Expected string or "}", but found "\{"/);
-	test("bad key", "{{}",
-		error => qr/Expected string or "}", but found "\{"/);
-	test("numeric key", "{1234: 2}",
-		error => qr/Expected string or "}", but found "1234"/);
-	test(
-		"second numeric key",
-		'{"a": "a", 1234: 2}',
-		error => qr/Expected string, but found "1234"/);
-	test(
-		"unclosed object with pair",
-		'{"key": "value"',
-		error => qr/input string ended unexpectedly/);
-	test("missing key value",
-		'{"key": }', error => qr/Expected JSON value, but found "}"/);
-	test(
-		"missing colon",
-		'{"key" 12345}',
-		error => qr/Expected ":", but found "12345"/);
-	test(
-		"missing comma",
-		'{"key": 12345 12345}',
-		error => qr/Expected "," or "}", but found "12345"/);
-	test("overnested array",
-		"[" x 6401, error => qr/maximum permitted depth is 6400/);
-	test("overclosed array",
-		"[]]", error => qr/Expected end of input, but found "]"/);
-	test("unexpected token in array",
-		"[ }}} ]", error => qr/Expected array element or "]", but found "}"/);
-	test("junk punctuation", "[ ||| ]", error => qr/Token "|" is invalid/);
-	test("missing comma in array",
-		"[123 123]", error => qr/Expected "," or "]", but found "123"/);
-	test("misspelled boolean", "tru", error => qr/Token "tru" is invalid/);
-	test(
-		"misspelled boolean in array",
-		"[tru]",
-		error => qr/Token "tru" is invalid/);
-	test(
-		"smashed top-level scalar",
-		"12zz",
-		error => qr/Token "12zz" is invalid/);
-	test(
-		"smashed scalar in array",
-		"[12zz]",
-		error => qr/Token "12zz" is invalid/);
-	test(
-		"unknown escape sequence",
-		'"hello\vworld"',
-		error => qr/Escape sequence "\\v" is invalid/);
-	test("unescaped control",
-		"\"hello\tworld\"",
-		error => qr/Character with value 0x09 must be escaped/);
-	test(
-		"incorrect escape count",
-		'"\\\\\\\\\\\\\\"',
-		error => qr/Token ""\\\\\\\\\\\\\\"" is invalid/);
-
-	# Case with three bytes: double-quote, backslash and <f5>.
-	# Both invalid-token and invalid-escape are possible errors, because for
-	# smaller chunk sizes the incremental parser skips the string parsing when
-	# it cannot find an ending quote.
-	test("incomplete UTF-8 sequence",
-		"\"\\\x{F5}",
-		error => qr/(Token|Escape sequence) ""?\\\x{F5}" is invalid/);
-}
-
-done_testing();
-- 
2.54.0

