v6-0002-Add-support-for-pytest-test-suites.patch
text/x-patch
Filename: v6-0002-Add-support-for-pytest-test-suites.patch
Type: text/x-patch
Part: 1
From 1f091d94a7523be6233ce93e2895c1fd7e1198a6 Mon Sep 17 00:00:00 2001
From: Jacob Champion <jacob.champion@enterprisedb.com>
Date: Wed, 13 Aug 2025 10:58:56 -0700
Subject: [PATCH v6 2/7] Add support for pytest test suites
Specify --enable-pytest/-Dpytest=enabled at configure time. This
contains no Postgres test logic -- it is just a "vanilla" pytest
skeleton.
I've written a custom pgtap output plugin, used by the Meson mtest
runner, to fully control what we see during CI test failures. The
pytest-tap plugin would have been preferable, but it's now in
maintenance mode, and it has problems with accidentally suppressing
important collection failures.
TODOs:
- The Chocolatey CI setup is subpar. Need to find a way to bless the
dependencies in use rather than pulling from pip... or maybe that will
be done by the image baker.
Co-authored-by: Jelte Fennema-Nio <postgres@jeltef.nl>
---
.cirrus.tasks.yml | 37 +++++--
.gitignore | 4 +
configure | 166 +++++++++++++++++++++++++++++-
configure.ac | 29 +++++-
meson.build | 107 +++++++++++++++++++
meson_options.txt | 8 +-
pyproject.toml | 21 ++++
src/Makefile.global.in | 29 ++++++
src/makefiles/meson.build | 2 +
src/test/Makefile | 1 +
src/test/meson.build | 1 +
src/test/pytest/Makefile | 20 ++++
src/test/pytest/README | 1 +
src/test/pytest/meson.build | 16 +++
src/test/pytest/pgtap.py | 198 ++++++++++++++++++++++++++++++++++++
src/tools/testwrap | 6 +-
16 files changed, 631 insertions(+), 15 deletions(-)
create mode 100644 pyproject.toml
create mode 100644 src/test/pytest/Makefile
create mode 100644 src/test/pytest/README
create mode 100644 src/test/pytest/meson.build
create mode 100644 src/test/pytest/pgtap.py
diff --git a/.cirrus.tasks.yml b/.cirrus.tasks.yml
index 038d043d00e..a83acb39e97 100644
--- a/.cirrus.tasks.yml
+++ b/.cirrus.tasks.yml
@@ -21,7 +21,8 @@ env:
# target to test, for all but windows
CHECK: check-world PROVE_FLAGS=$PROVE_FLAGS
- CHECKFLAGS: -Otarget
+ # TODO were we avoiding --keep-going on purpose?
+ CHECKFLAGS: -Otarget --keep-going
PROVE_FLAGS: --timer
# Build test dependencies as part of the build step, to see compiler
# errors/warnings in one place.
@@ -44,6 +45,7 @@ env:
-Dldap=enabled
-Dssl=openssl
-Dtap_tests=enabled
+ -Dpytest=enabled
-Dplperl=enabled
-Dplpython=enabled
-Ddocs=enabled
@@ -225,7 +227,9 @@ task:
chown root:postgres /tmp/cores
sysctl kern.corefile='/tmp/cores/%N.%P.core'
setup_additional_packages_script: |
- #pkg install -y ...
+ pkg install -y \
+ py311-packaging \
+ py311-pytest
# NB: Intentionally build without -Dllvm. The freebsd image size is already
# large enough to make VM startup slow, and even without llvm freebsd
@@ -317,7 +321,10 @@ task:
-Dpam=enabled
setup_additional_packages_script: |
- #pkgin -y install ...
+ pkgin -y install \
+ py312-packaging \
+ py312-test
+ ln -s /usr/pkg/bin/pytest-3.12 /usr/pkg/bin/pytest
<<: *netbsd_task_template
- name: OpenBSD - Meson
@@ -337,7 +344,9 @@ task:
-Duuid=e2fs
setup_additional_packages_script: |
- #pkg_add -I ...
+ pkg_add -I \
+ py3-test \
+ py3-packaging
# Always core dump to ${CORE_DUMP_DIR}
set_core_dump_script: sysctl -w kern.nosuidcoredump=2
<<: *openbsd_task_template
@@ -496,8 +505,10 @@ task:
EOF
setup_additional_packages_script: |
- #apt-get update
- #DEBIAN_FRONTEND=noninteractive apt-get -y install ...
+ apt-get update
+ DEBIAN_FRONTEND=noninteractive apt-get -y install \
+ python3-pytest \
+ python3-packaging
matrix:
# SPECIAL:
@@ -521,14 +532,15 @@ task:
set -e
./configure \
--enable-cassert --enable-injection-points --enable-debug \
- --enable-tap-tests --enable-nls \
+ --enable-tap-tests --enable-pytest --enable-nls \
--with-segsize-blocks=6 \
--with-libnuma \
--with-liburing \
\
${LINUX_CONFIGURE_FEATURES} \
\
- CLANG="ccache clang"
+ CLANG="ccache clang" \
+ PYTEST="env LD_PRELOAD=/lib/x86_64-linux-gnu/libasan.so.8 pytest"
EOF
build_script: su postgres -c "make -s -j${BUILD_JOBS} world-bin"
upload_caches: ccache
@@ -665,6 +677,8 @@ task:
p5.34-io-tty
p5.34-ipc-run
python312
+ py312-packaging
+ py312-pytest
tcl
zstd
@@ -714,6 +728,7 @@ task:
sh src/tools/ci/ci_macports_packages.sh $MACOS_PACKAGE_LIST
# system python doesn't provide headers
sudo /opt/local/bin/port select python3 python312
+ sudo /opt/local/bin/port select pytest pytest312
# Make macports install visible for subsequent steps
echo PATH=/opt/local/sbin/:/opt/local/bin/:$PATH >> $CIRRUS_ENV
upload_caches: macports
@@ -787,6 +802,8 @@ task:
-Dldap=enabled
-Dssl=openssl
-Dtap_tests=enabled
+ -Dpytest=enabled
+ -DPYTEST=c:\Windows\system32\config\systemprofile\AppData\Roaming\Python\Python310\Scripts\pytest.exe
-Dplperl=enabled
-Dplpython=enabled
@@ -795,8 +812,10 @@ task:
depends_on: SanityCheck
only_if: $CI_WINDOWS_ENABLED
+ # XXX Does Chocolatey really not have any Python package installers?
setup_additional_packages_script: |
REM choco install -y --no-progress ...
+ pip3 install --user packaging pytest
setup_hosts_file_script: |
echo 127.0.0.1 pg-loadbalancetest >> c:\Windows\System32\Drivers\etc\hosts
@@ -859,7 +878,7 @@ task:
folder: ${CCACHE_DIR}
setup_additional_packages_script: |
- REM C:\msys64\usr\bin\pacman.exe -S --noconfirm ...
+ C:\msys64\usr\bin\pacman.exe -S --noconfirm mingw-w64-ucrt-x86_64-python-pytest
mingw_info_script: |
%BASH% -c "where gcc"
diff --git a/.gitignore b/.gitignore
index 4e911395fe3..a8c73bba9ba 100644
--- a/.gitignore
+++ b/.gitignore
@@ -31,6 +31,8 @@ win32ver.rc
*.exe
lib*dll.def
lib*.pc
+__pycache__/
+*.egg-info/
# Local excludes in root directory
/GNUmakefile
@@ -43,3 +45,5 @@ lib*.pc
/Release/
/tmp_install/
/portlock/
+/.venv/
+/uv.lock
diff --git a/configure b/configure
index 14ad0a5006f..f28db423cd8 100755
--- a/configure
+++ b/configure
@@ -630,6 +630,8 @@ vpath_build
PG_SYSROOT
PG_VERSION_NUM
LDFLAGS_EX_BE
+UV
+PYTEST
PROVE
DBTOEPUB
FOP
@@ -772,6 +774,7 @@ CFLAGS
CC
enable_injection_points
PG_TEST_EXTRA
+enable_pytest
enable_tap_tests
enable_dtrace
DTRACEFLAGS
@@ -850,6 +853,7 @@ enable_profiling
enable_coverage
enable_dtrace
enable_tap_tests
+enable_pytest
enable_injection_points
with_blocksize
with_segsize
@@ -1550,7 +1554,10 @@ Optional Features:
--enable-profiling build with profiling enabled
--enable-coverage build with coverage testing instrumentation
--enable-dtrace build with DTrace support
- --enable-tap-tests enable TAP tests (requires Perl and IPC::Run)
+ --enable-tap-tests enable (Perl-based) TAP tests (requires Perl and
+ IPC::Run)
+ --enable-pytest enable (Python-based) pytest suites (requires
+ Python)
--enable-injection-points
enable injection points (for testing)
--enable-depend turn on automatic dependency tracking
@@ -3632,7 +3639,7 @@ fi
#
-# TAP tests
+# Test frameworks
#
@@ -3660,6 +3667,32 @@ fi
+
+# Check whether --enable-pytest was given.
+if test "${enable_pytest+set}" = set; then :
+ enableval=$enable_pytest;
+ case $enableval in
+ yes)
+ :
+ ;;
+ no)
+ :
+ ;;
+ *)
+ as_fn_error $? "no argument expected for --enable-pytest option" "$LINENO" 5
+ ;;
+ esac
+
+else
+ enable_pytest=no
+
+fi
+
+
+
+
+
+
#
# Injection points
#
@@ -19229,6 +19262,135 @@ $as_echo "$modulestderr" >&6; }
fi
fi
+if test "$enable_pytest" = yes; then
+ if test -z "$PYTEST"; then
+ for ac_prog in pytest py.test
+do
+ # Extract the first word of "$ac_prog", so it can be a program name with args.
+set dummy $ac_prog; ac_word=$2
+{ $as_echo "$as_me:${as_lineno-$LINENO}: checking for $ac_word" >&5
+$as_echo_n "checking for $ac_word... " >&6; }
+if ${ac_cv_path_PYTEST+:} false; then :
+ $as_echo_n "(cached) " >&6
+else
+ case $PYTEST in
+ [\\/]* | ?:[\\/]*)
+ ac_cv_path_PYTEST="$PYTEST" # Let the user override the test with a path.
+ ;;
+ *)
+ as_save_IFS=$IFS; IFS=$PATH_SEPARATOR
+for as_dir in $PATH
+do
+ IFS=$as_save_IFS
+ test -z "$as_dir" && as_dir=.
+ for ac_exec_ext in '' $ac_executable_extensions; do
+ if as_fn_executable_p "$as_dir/$ac_word$ac_exec_ext"; then
+ ac_cv_path_PYTEST="$as_dir/$ac_word$ac_exec_ext"
+ $as_echo "$as_me:${as_lineno-$LINENO}: found $as_dir/$ac_word$ac_exec_ext" >&5
+ break 2
+ fi
+done
+ done
+IFS=$as_save_IFS
+
+ ;;
+esac
+fi
+PYTEST=$ac_cv_path_PYTEST
+if test -n "$PYTEST"; then
+ { $as_echo "$as_me:${as_lineno-$LINENO}: result: $PYTEST" >&5
+$as_echo "$PYTEST" >&6; }
+else
+ { $as_echo "$as_me:${as_lineno-$LINENO}: result: no" >&5
+$as_echo "no" >&6; }
+fi
+
+
+ test -n "$PYTEST" && break
+done
+
+else
+ # Report the value of PYTEST in configure's output in all cases.
+ { $as_echo "$as_me:${as_lineno-$LINENO}: checking for PYTEST" >&5
+$as_echo_n "checking for PYTEST... " >&6; }
+ { $as_echo "$as_me:${as_lineno-$LINENO}: result: $PYTEST" >&5
+$as_echo "$PYTEST" >&6; }
+fi
+
+ if test -z "$PYTEST"; then
+ # If pytest not found, try installing with uv
+ if test -z "$UV"; then
+ for ac_prog in uv
+do
+ # Extract the first word of "$ac_prog", so it can be a program name with args.
+set dummy $ac_prog; ac_word=$2
+{ $as_echo "$as_me:${as_lineno-$LINENO}: checking for $ac_word" >&5
+$as_echo_n "checking for $ac_word... " >&6; }
+if ${ac_cv_path_UV+:} false; then :
+ $as_echo_n "(cached) " >&6
+else
+ case $UV in
+ [\\/]* | ?:[\\/]*)
+ ac_cv_path_UV="$UV" # Let the user override the test with a path.
+ ;;
+ *)
+ as_save_IFS=$IFS; IFS=$PATH_SEPARATOR
+for as_dir in $PATH
+do
+ IFS=$as_save_IFS
+ test -z "$as_dir" && as_dir=.
+ for ac_exec_ext in '' $ac_executable_extensions; do
+ if as_fn_executable_p "$as_dir/$ac_word$ac_exec_ext"; then
+ ac_cv_path_UV="$as_dir/$ac_word$ac_exec_ext"
+ $as_echo "$as_me:${as_lineno-$LINENO}: found $as_dir/$ac_word$ac_exec_ext" >&5
+ break 2
+ fi
+done
+ done
+IFS=$as_save_IFS
+
+ ;;
+esac
+fi
+UV=$ac_cv_path_UV
+if test -n "$UV"; then
+ { $as_echo "$as_me:${as_lineno-$LINENO}: result: $UV" >&5
+$as_echo "$UV" >&6; }
+else
+ { $as_echo "$as_me:${as_lineno-$LINENO}: result: no" >&5
+$as_echo "no" >&6; }
+fi
+
+
+ test -n "$UV" && break
+done
+
+else
+ # Report the value of UV in configure's output in all cases.
+ { $as_echo "$as_me:${as_lineno-$LINENO}: checking for UV" >&5
+$as_echo_n "checking for UV... " >&6; }
+ { $as_echo "$as_me:${as_lineno-$LINENO}: result: $UV" >&5
+$as_echo "$UV" >&6; }
+fi
+
+ if test -n "$UV"; then
+ { $as_echo "$as_me:${as_lineno-$LINENO}: checking whether uv can install pytest dependencies" >&5
+$as_echo_n "checking whether uv can install pytest dependencies... " >&6; }
+ if "$UV" pip install "$srcdir" >&5 2>&1; then
+ { $as_echo "$as_me:${as_lineno-$LINENO}: result: yes" >&5
+$as_echo "yes" >&6; }
+ PYTEST="$UV run pytest"
+ else
+ { $as_echo "$as_me:${as_lineno-$LINENO}: result: no" >&5
+$as_echo "no" >&6; }
+ as_fn_error $? "pytest not found and uv failed to install dependencies" "$LINENO" 5
+ fi
+ else
+ as_fn_error $? "pytest not found" "$LINENO" 5
+ fi
+ fi
+fi
+
# If compiler will take -Wl,--as-needed (or various platform-specific
# spellings thereof) then add that to LDFLAGS. This is much easier than
# trying to filter LIBS to the minimum for each executable.
diff --git a/configure.ac b/configure.ac
index 01b3bbc1be8..8226e2a1342 100644
--- a/configure.ac
+++ b/configure.ac
@@ -225,11 +225,16 @@ AC_SUBST(DTRACEFLAGS)])
AC_SUBST(enable_dtrace)
#
-# TAP tests
+# Test frameworks
#
PGAC_ARG_BOOL(enable, tap-tests, no,
- [enable TAP tests (requires Perl and IPC::Run)])
+ [enable (Perl-based) TAP tests (requires Perl and IPC::Run)])
AC_SUBST(enable_tap_tests)
+
+PGAC_ARG_BOOL(enable, pytest, no,
+ [enable (Python-based) pytest suites (requires Python)])
+AC_SUBST(enable_pytest)
+
AC_ARG_VAR(PG_TEST_EXTRA,
[enable selected extra tests (overridden at runtime by PG_TEST_EXTRA environment variable)])
@@ -2412,6 +2417,26 @@ if test "$enable_tap_tests" = yes; then
fi
fi
+if test "$enable_pytest" = yes; then
+ PGAC_PATH_PROGS(PYTEST, pytest py.test)
+ if test -z "$PYTEST"; then
+ # If pytest not found, try installing with uv
+ PGAC_PATH_PROGS(UV, uv)
+ if test -n "$UV"; then
+ AC_MSG_CHECKING([whether uv can install pytest dependencies])
+ if "$UV" pip install "$srcdir" >&AS_MESSAGE_LOG_FD 2>&1; then
+ AC_MSG_RESULT([yes])
+ PYTEST="$UV run pytest"
+ else
+ AC_MSG_RESULT([no])
+ AC_MSG_ERROR([pytest not found and uv failed to install dependencies])
+ fi
+ else
+ AC_MSG_ERROR([pytest not found])
+ fi
+ fi
+fi
+
# If compiler will take -Wl,--as-needed (or various platform-specific
# spellings thereof) then add that to LDFLAGS. This is much easier than
# trying to filter LIBS to the minimum for each executable.
diff --git a/meson.build b/meson.build
index 551e27f5eb3..2ec125116a2 100644
--- a/meson.build
+++ b/meson.build
@@ -1711,6 +1711,41 @@ endif
+###############################################################
+# Library: pytest
+###############################################################
+
+pytest_enabled = false
+pytest = not_found_dep
+uv = not_found_dep
+use_uv = false
+
+pytestopt = get_option('pytest')
+if not pytestopt.disabled()
+ pytest = find_program(get_option('PYTEST'), native: true, required: false)
+
+ # If pytest not found, try installing with uv
+ if not pytest.found()
+ uv = find_program('uv', native: true, required: false)
+ if uv.found()
+ message('Installing pytest dependencies with uv...')
+ uv_install = run_command(uv, 'pip', 'install', meson.project_source_root(), check: false)
+ if uv_install.returncode() == 0
+ use_uv = true
+ pytest_enabled = true
+ endif
+ endif
+ else
+ pytest_enabled = true
+ endif
+
+ if not pytest_enabled and pytestopt.enabled()
+ error('pytest not found')
+ endif
+endif
+
+
+
###############################################################
# Library: zstd
###############################################################
@@ -3808,6 +3843,76 @@ foreach test_dir : tests
)
endforeach
install_suites += test_group
+ elif kind == 'pytest'
+ testwrap_pytest = testwrap_base
+ if not pytest_enabled
+ testwrap_pytest += ['--skip', 'pytest not enabled']
+ endif
+
+ if use_uv
+ test_command = [uv.full_path(), 'run', 'pytest']
+ elif pytest_enabled
+ test_command = [pytest.full_path()]
+ else
+ # Dummy value - test will be skipped anyway
+ test_command = ['pytest']
+ endif
+ test_command += [
+ '-c', meson.project_source_root() / 'pyproject.toml',
+ '--verbose',
+ '-p', 'pgtap', # enable our test reporter plugin
+ '-ra', # show skipped and xfailed tests too
+ ]
+
+ # Add temporary install, the build directory for non-installed binaries and
+ # also test/ for non-installed test binaries built separately.
+ env = test_env
+ env.prepend('PATH', temp_install_bindir, test_dir['bd'], test_dir['bd'] / 'test')
+ temp_install_datadir = '@0@@1@'.format(test_install_destdir, dir_prefix / dir_data)
+ env.set('share_contrib_dir', temp_install_datadir / 'contrib')
+ # We also configure the same PYTHONPATH in the pytest settings in
+ # pyproject.toml, but pytest versions below 8.4 only actually use that
+ # value after plugin loading. So we need to configure it here too. This
+ # won't help people manually running pytest outside of meson/make, but we
+ # expect those to use a recent enough version of pytest anyway (and if
+ # not they can manually configure PYTHONPATH too).
+ env.prepend('PYTHONPATH', meson.project_source_root() / 'src' / 'test' / 'pytest')
+
+ foreach name, value : t.get('env', {})
+ env.set(name, value)
+ endforeach
+
+ test_group = test_dir['name']
+ test_kwargs = {
+ 'protocol': 'tap',
+ 'suite': test_group,
+ 'timeout': 1000,
+ 'depends': test_deps + t.get('deps', []),
+ 'env': env,
+ } + t.get('test_kwargs', {})
+
+ foreach onetest : t['tests']
+ # Make test names prettier, remove pyt/ and .py
+ onetest_p = onetest
+ if onetest_p.startswith('pyt/')
+ onetest_p = onetest.split('pyt/')[1]
+ endif
+ if onetest_p.endswith('.py')
+ onetest_p = fs.stem(onetest_p)
+ endif
+
+ test(test_dir['name'] / onetest_p,
+ python,
+ kwargs: test_kwargs,
+ args: testwrap_pytest + [
+ '--testgroup', test_dir['name'],
+ '--testname', onetest_p,
+ '--', test_command,
+ test_dir['sd'] / onetest,
+ ],
+ )
+ endforeach
+ install_suites += test_group
else
error('unknown kind @0@ of test in @1@'.format(kind, test_dir['sd']))
endif
@@ -3982,6 +4087,7 @@ summary(
'dtrace': dtrace,
'flex': '@0@ @1@'.format(flex.full_path(), flex_version),
'prove': prove,
+ 'pytest': pytest,
},
section: 'Programs',
)
@@ -4022,6 +4128,7 @@ summary(
summary(
{
'tap': tap_tests_enabled,
+ 'pytest': pytest_enabled,
},
section: 'Other features',
list_sep: ' ',
diff --git a/meson_options.txt b/meson_options.txt
index 06bf5627d3c..88f22e699d9 100644
--- a/meson_options.txt
+++ b/meson_options.txt
@@ -41,7 +41,10 @@ option('cassert', type: 'boolean', value: false,
description: 'Enable assertion checks (for debugging)')
option('tap_tests', type: 'feature', value: 'auto',
- description: 'Enable TAP tests')
+ description: 'Enable (Perl-based) TAP tests')
+
+option('pytest', type: 'feature', value: 'auto',
+ description: 'Enable (Python-based) pytest suites')
option('injection_points', type: 'boolean', value: false,
description: 'Enable injection points')
@@ -195,6 +198,9 @@ option('PERL', type: 'string', value: 'perl',
option('PROVE', type: 'string', value: 'prove',
description: 'Path to prove binary')
+option('PYTEST', type: 'array', value: ['pytest', 'py.test'],
+ description: 'Path to pytest binary')
+
option('PYTHON', type: 'array', value: ['python3', 'python'],
description: 'Path to python binary')
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 00000000000..60abb4d0655
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,21 @@
+[project]
+name = "postgresql-hackers-tooling"
+version = "0.1.0"
+description = "Pytest infrastructure for PostgreSQL"
+requires-python = ">=3.6"
+dependencies = [
+ # pytest 7.0 was the last version which supported Python 3.6, but the BSDs
+ # have started putting 8.x into ports, so we support both. (pytest 8 can be
+ # used throughout once we drop support for Python 3.7.)
+ "pytest >= 7.0, < 10",
+
+ # Any other dependencies are effectively optional (added below). We import
+ # these libraries using pytest.importorskip(). So tests will be skipped if
+ # they are not available.
+]
+
+[tool.pytest.ini_options]
+minversion = "7.0"
+
+# Common test code can be found here.
+pythonpath = ["src/test/pytest"]
diff --git a/src/Makefile.global.in b/src/Makefile.global.in
index 371cd7eba2c..160cdffd4f1 100644
--- a/src/Makefile.global.in
+++ b/src/Makefile.global.in
@@ -211,6 +211,7 @@ enable_dtrace = @enable_dtrace@
enable_coverage = @enable_coverage@
enable_injection_points = @enable_injection_points@
enable_tap_tests = @enable_tap_tests@
+enable_pytest = @enable_pytest@
python_includespec = @python_includespec@
python_libdir = @python_libdir@
@@ -354,6 +355,7 @@ MSGFMT = @MSGFMT@
MSGFMT_FLAGS = @MSGFMT_FLAGS@
MSGMERGE = @MSGMERGE@
OPENSSL = @OPENSSL@
+PYTEST = @PYTEST@
PYTHON = @PYTHON@
TAR = @TAR@
XGETTEXT = @XGETTEXT@
@@ -508,6 +510,33 @@ prove_installcheck = @echo "TAP tests not enabled. Try configuring with --enable
prove_check = $(prove_installcheck)
endif
+ifeq ($(enable_pytest),yes)
+
+pytest_installcheck = @echo "Installcheck is not currently supported for pytest."
+
+# We also configure the same PYTHONPATH in the pytest settings in
+# pyproject.toml, but pytest versions below 8.4 only actually use that value
+# after plugin loading. So we need to configure it here too. This won't help
+# people manually running pytest outside of meson/make, but we expect those to
+# use a recent enough version of pytest anyway (and if not they can manually
+# configure PYTHONPATH too).
+define pytest_check
+echo "# +++ pytest check in $(subdir) +++" && \
+rm -rf '$(CURDIR)'/tmp_check && \
+$(MKDIR_P) '$(CURDIR)'/tmp_check && \
+cd $(srcdir) && \
+ TESTLOGDIR='$(CURDIR)/tmp_check/log' \
+ TESTDATADIR='$(CURDIR)/tmp_check' \
+ PYTHONPATH='$(abs_top_srcdir)/src/test/pytest:$$PYTHONPATH' \
+ $(with_temp_install) \
+ $(PYTEST) -c '$(abs_top_srcdir)/pyproject.toml' --verbose -ra ./pyt/
+endef
+
+else
+pytest_installcheck = @echo "pytest is not enabled. Try configuring with --enable-pytest"
+pytest_check = $(pytest_installcheck)
+endif
+
# Installation.
install_bin = @install_bin@
diff --git a/src/makefiles/meson.build b/src/makefiles/meson.build
index c6edf14ec44..5b9a804aa94 100644
--- a/src/makefiles/meson.build
+++ b/src/makefiles/meson.build
@@ -56,6 +56,7 @@ pgxs_kv = {
'enable_nls': libintl.found() ? 'yes' : 'no',
'enable_injection_points': get_option('injection_points') ? 'yes' : 'no',
'enable_tap_tests': tap_tests_enabled ? 'yes' : 'no',
+ 'enable_pytest': pytest_enabled ? 'yes' : 'no',
'enable_debug': get_option('debug') ? 'yes' : 'no',
'enable_coverage': 'no',
'enable_dtrace': dtrace.found() ? 'yes' : 'no',
@@ -145,6 +146,7 @@ pgxs_bins = {
'OPENSSL': openssl,
'PERL': perl,
'PROVE': prove,
+ 'PYTEST': pytest,
'PYTHON': python,
'TAR': tar,
'ZSTD': program_zstd,
diff --git a/src/test/Makefile b/src/test/Makefile
index 3eb0a06abb4..0be9771d71f 100644
--- a/src/test/Makefile
+++ b/src/test/Makefile
@@ -18,6 +18,7 @@ SUBDIRS = \
modules \
perl \
postmaster \
+ pytest \
recovery \
regress \
subscription
diff --git a/src/test/meson.build b/src/test/meson.build
index ccc31d6a86a..d08a6ef61c2 100644
--- a/src/test/meson.build
+++ b/src/test/meson.build
@@ -5,6 +5,7 @@ subdir('isolation')
subdir('authentication')
subdir('postmaster')
+subdir('pytest')
subdir('recovery')
subdir('subscription')
subdir('modules')
diff --git a/src/test/pytest/Makefile b/src/test/pytest/Makefile
new file mode 100644
index 00000000000..2bdca96ccbe
--- /dev/null
+++ b/src/test/pytest/Makefile
@@ -0,0 +1,20 @@
+#-------------------------------------------------------------------------
+#
+# Makefile for pytest
+#
+# Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/test/pytest/Makefile
+#
+#-------------------------------------------------------------------------
+
+subdir = src/test/pytest
+top_builddir = ../../..
+include $(top_builddir)/src/Makefile.global
+
+check:
+ $(pytest_check)
+
+clean distclean maintainer-clean:
+ rm -rf tmp_check
diff --git a/src/test/pytest/README b/src/test/pytest/README
new file mode 100644
index 00000000000..1333ed77b7e
--- /dev/null
+++ b/src/test/pytest/README
@@ -0,0 +1 @@
+TODO
diff --git a/src/test/pytest/meson.build b/src/test/pytest/meson.build
new file mode 100644
index 00000000000..abd128dfa24
--- /dev/null
+++ b/src/test/pytest/meson.build
@@ -0,0 +1,16 @@
+# Copyright (c) 2025, PostgreSQL Global Development Group
+
+if not pytest_enabled
+ subdir_done()
+endif
+
+tests += {
+ 'name': 'pytest',
+ 'sd': meson.current_source_dir(),
+ 'bd': meson.current_build_dir(),
+ 'pytest': {
+ 'tests': [
+ 'pyt/test_something.py',
+ ],
+ },
+}
diff --git a/src/test/pytest/pgtap.py b/src/test/pytest/pgtap.py
new file mode 100644
index 00000000000..c92cad98d95
--- /dev/null
+++ b/src/test/pytest/pgtap.py
@@ -0,0 +1,198 @@
+# Copyright (c) 2025, PostgreSQL Global Development Group
+
+import os
+import sys
+
+import pytest
+
+#
+# Helpers
+#
+
+
+class TAP:
+ """
+ A basic API for reporting via the TAP protocol.
+ """
+
+ def __init__(self):
+ self.count = 0
+
+ # XXX interacts poorly with testwrap's boilerplate diagnostics
+ # self.print("TAP version 13")
+
+ def expect(self, num: int):
+ self.print(f"1..{num}")
+
+ def print(self, *args):
+ print(*args, file=sys.__stdout__)
+
+ def ok(self, name: str):
+ self.count += 1
+ self.print("ok", self.count, "-", name)
+
+ def skip(self, name: str, reason: str):
+ self.count += 1
+ self.print("ok", self.count, "-", name, "# skip", reason)
+
+ def fail(self, name: str, details: str):
+ self.count += 1
+ self.print("not ok", self.count, "-", name)
+
+ # mtest has some odd behavior around TAP tests where it won't print
+ # diagnostics on failure if they're part of the stdout stream, so we
+ # might as well just dump the details directly to stderr instead.
+ print(details, file=sys.__stderr__)
+
+
+tap = TAP()
+
+
+class TestNotes:
+ """
+ Annotations for a single test. The existing pytest hooks keep interesting
+ information somewhat separated across the different stages
+ (setup/test/teardown), so this class is used to correlate them.
+ """
+
+ skipped = False
+ skip_reason = None
+
+ failed = False
+ details = ""
+
+
+# Register a custom key in the stash dictionary for keeping our TestNotes.
+notes_key = pytest.StashKey[TestNotes]()
+
+
+#
+# Hook Implementations
+#
+
+
+@pytest.hookimpl(tryfirst=True)
+def pytest_configure(config):
+ """
+ Hijacks the standard streams as soon as possible during pytest startup. The
+ pytest-formatted output gets logged to file instead, and we'll use the
+ original sys.__stdout__/__stderr__ streams for the TAP protocol.
+ """
+ logdir = os.getenv("TESTLOGDIR")
+ if not logdir:
+ raise RuntimeError("pgtap requires the TESTLOGDIR envvar to be set")
+
+ os.makedirs(logdir)
+ logpath = os.path.join(logdir, "pytest.log")
+ sys.stdout = sys.stderr = open(logpath, "a", buffering=1)
+
+
+@pytest.hookimpl(trylast=True)
+def pytest_sessionfinish(session, exitstatus):
+ """
+ Suppresses nonzero exit codes due to failed tests. (In that case, we want
+ Meson to report a failure count, not a generic ERROR.)
+ """
+ if exitstatus == pytest.ExitCode.TESTS_FAILED:
+ session.exitstatus = pytest.ExitCode.OK
+
+
+@pytest.hookimpl
+def pytest_collectreport(report):
+ # Include collection failures directly in Meson error output.
+ if report.failed:
+ print(report.longreprtext, file=sys.__stderr__)
+
+
+@pytest.hookimpl
+def pytest_internalerror(excrepr, excinfo):
+ # Include internal errors directly in Meson error output.
+ print(excrepr, file=sys.__stderr__)
+
+
+#
+# Hook Wrappers
+#
+# In pytest parlance, a "wrapper" for a hook can inspect and optionally modify
+# existing hooks' behavior, but it does not replace the hook chain. This is done
+# through a generator-style API which chains the hooks together (see the use of
+# `yield`).
+#
+
+
+@pytest.hookimpl(hookwrapper=True)
+def pytest_collection(session):
+ """Reports the number of gathered tests after collection is finished."""
+ res = yield
+ tap.expect(session.testscollected)
+ return res
+
+
+@pytest.hookimpl(hookwrapper=True)
+def pytest_runtest_makereport(item, call):
+ """
+ Annotates a test item with our TestNotes and grabs relevant information for
+ reporting.
+
+ This is called multiple times per test, so it's not correct to print the TAP
+ result here. (A test and its teardown stage can both fail, and we want to
+ see the details for both.) We instead combine all the information for use by
+ our pytest_runtest_protocol wrapper later on.
+ """
+ res = yield
+
+ if notes_key not in item.stash:
+ item.stash[notes_key] = TestNotes()
+ notes = item.stash[notes_key]
+
+ report = res.get_result()
+ if report.passed:
+ pass # no annotation needed
+
+ elif report.skipped:
+ notes.skipped = True
+ _, _, notes.skip_reason = report.longrepr
+
+ elif report.failed:
+ notes.failed = True
+
+ if not notes.details:
+ notes.details += "{:_^72}\n\n".format(f" {report.head_line} ")
+
+ if report.when in ("setup", "teardown"):
+ notes.details += "\n{:_^72}\n\n".format(
+ f" Error during {report.when} of {report.head_line} "
+ )
+
+ notes.details += report.longreprtext + "\n"
+
+ # Include captured stdout/stderr/log in failure output
+ for section_name, section_content in report.sections:
+ if section_content.strip():
+ notes.details += "\n{:-^72}\n".format(f" {section_name} ")
+ notes.details += section_content + "\n"
+
+ else:
+ raise RuntimeError("pytest_runtest_makereport received unknown test status")
+
+ return res
+
+
+@pytest.hookimpl(hookwrapper=True)
+def pytest_runtest_protocol(item, nextitem):
+ """
+ Reports the TAP result for this test item using our gathered TestNotes.
+ """
+ res = yield
+
+ assert notes_key in item.stash, "pgtap didn't annotate a test item?"
+ notes = item.stash[notes_key]
+
+ if notes.failed:
+ tap.fail(item.nodeid, notes.details)
+ elif notes.skipped:
+ tap.skip(item.nodeid, notes.skip_reason)
+ else:
+ tap.ok(item.nodeid)
+
+ return res
diff --git a/src/tools/testwrap b/src/tools/testwrap
index e91296ecd15..346f86b8ea3 100755
--- a/src/tools/testwrap
+++ b/src/tools/testwrap
@@ -42,7 +42,11 @@ open(os.path.join(testdir, 'test.start'), 'x')
env_dict = {**os.environ,
'TESTDATADIR': os.path.join(testdir, 'data'),
- 'TESTLOGDIR': os.path.join(testdir, 'log')}
+ 'TESTLOGDIR': os.path.join(testdir, 'log'),
+ # Prevent emitting terminal capability sequences that pollute the
+ # TAP output stream (i.e.\033[?1034h). This happens on OpenBSD with
+ # pytest for unknown reasons.
+ 'TERM': ''}
# The configuration time value of PG_TEST_EXTRA is supplied via argument
--
2.52.0