From c4df7dfd125106144d828b790d236b4e7f83d169 Mon Sep 17 00:00:00 2001 From: Jacob Champion Date: Wed, 13 Aug 2025 10:58:56 -0700 Subject: [PATCH v2 2/6] 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. test_something.py is intended to show a sample failure in the CI. TODOs: - OpenBSD has an ANSI-related terminal bug, but I'm not sure if the bug is in Cirrus, the image, pytest, Python, or readline. The TERM envvar is unset to work around it. If this workaround is removed, a bad ANSI escape is inserted into the pgtap output and mtest is unable to parse it. - 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. --- .cirrus.tasks.yml | 38 +++-- .gitignore | 1 + config/check_pytest.py | 150 ++++++++++++++++++++ config/conftest.py | 18 +++ config/pytest-requirements.txt | 21 +++ configure | 108 +++++++++++++- configure.ac | 25 +++- meson.build | 92 ++++++++++++ meson_options.txt | 8 +- pytest.ini | 6 + src/Makefile.global.in | 23 +++ src/makefiles/meson.build | 2 + src/test/Makefile | 11 +- src/test/meson.build | 1 + src/test/pytest/Makefile | 20 +++ src/test/pytest/README | 1 + src/test/pytest/meson.build | 16 +++ src/test/pytest/plugins/pgtap.py | 193 ++++++++++++++++++++++++++ src/test/pytest/pyt/test_something.py | 17 +++ 19 files changed, 736 insertions(+), 15 deletions(-) create mode 100644 config/check_pytest.py create mode 100644 config/conftest.py create mode 100644 config/pytest-requirements.txt create mode 100644 pytest.ini 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/plugins/pgtap.py create mode 100644 src/test/pytest/pyt/test_something.py diff --git a/.cirrus.tasks.yml b/.cirrus.tasks.yml index eca9d62fc22..80f9b394bd2 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 @@ -222,7 +224,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 @@ -311,7 +315,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 @@ -322,6 +329,7 @@ task: OS_NAME: openbsd IMAGE_FAMILY: pg-ci-openbsd-postgres PKGCONFIG_PATH: '/usr/lib/pkgconfig:/usr/local/lib/pkgconfig' + TERM: # TODO why does pytest print ANSI escapes on OpenBSD? MESON_FEATURES: >- -Dbsd_auth=enabled @@ -330,7 +338,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 @@ -489,8 +499,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: @@ -513,14 +525,15 @@ task: su postgres <<-EOF ./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-16" + CLANG="ccache clang-16" \ + 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 @@ -650,6 +663,8 @@ task: p5.34-io-tty p5.34-ipc-run python312 + py312-packaging + py312-pytest tcl zstd @@ -699,6 +714,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 @@ -772,6 +788,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 @@ -780,8 +798,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 @@ -844,7 +864,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-packaging mingw-w64-ucrt-x86_64-python-pytest mingw_info_script: | %BASH% -c "where gcc" diff --git a/.gitignore b/.gitignore index 4e911395fe3..268426003b1 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,7 @@ win32ver.rc *.exe lib*dll.def lib*.pc +__pycache__/ # Local excludes in root directory /GNUmakefile diff --git a/config/check_pytest.py b/config/check_pytest.py new file mode 100644 index 00000000000..1562d16bcda --- /dev/null +++ b/config/check_pytest.py @@ -0,0 +1,150 @@ +# Copyright (c) 2025, PostgreSQL Global Development Group +# +# Verify that pytest-requirements.txt is satisfied. This would probably be +# easier with pip, but requiring pip on build machines is a non-starter for +# many. +# +# This is coded as a pytest suite in order to check the Python distribution in +# use by pytest, as opposed to the Python distribution being linked against +# Postgres. In some setups they are separate. +# +# The design philosophy of this script is to bend over backwards to help people +# figure out what is missing. The target audience for error output is the +# buildfarm operator who just wants to get the tests running, not the test +# developer who presumably already knows how to solve these problems. + +import importlib +import sys +from typing import List, Union # needed for earlier Python versions + +# importlib.metadata is part of the standard library from 3.8 onwards. Earlier +# Python versions have an official backport called importlib_metadata, which can +# generally be installed as a separate OS package (python3-importlib-metadata). +# This complication can be removed once we stop supporting Python 3.7. +try: + from importlib import metadata +except ImportError: + try: + import importlib_metadata as metadata + except ImportError: + # package_version() will need to fall back. This is unlikely to happen + # in practice, because pytest 7.x depends on importlib_metadata itself. + metadata = None + + +def report(*args): + """ + Prints a configure-time message to the user. (The configure scripts will + display these messages and ignore the output from the pytest suite.) This + assumes --capture=no is in use, to avoid pytest's standard stream capture. + """ + print(*args, file=sys.stderr) + + +def package_version(pkg: str) -> Union[str, None]: + """ + Returns the version of the named package, or None if the package is not + installed. + + This function prefers to use the distribution package version, if we have + the necessary prerequisites. Otherwise it will fall back to the __version__ + of the imported module, which aligns with pytest.importorskip(). + """ + if metadata is not None: + try: + return metadata.version(pkg) + except metadata.PackageNotFoundError: + return None + + # This is an older Python and we don't have importlib_metadata. Fall back to + # __version__ instead. + try: + mod = importlib.import_module(pkg) + except ModuleNotFoundError: + return None + + if hasattr(mod, "__version__"): + return mod.__version__ + + # We're out of options. If this turns out to cause problems in practice, we + # might need to require importlib_metadata on older buildfarm members. But + # since our top-level requirements list will be small, and this possibility + # will eventually age out with newer Pythons, don't spend more effort on + # this case for now. + report(f"Fix check_pytest.py! {pkg} has no __version__") + assert False, "internal error in package_version()" + + +def packaging_check(requirements: List[str]) -> bool: + """ + Reports the status of each required package to the configure program. + Returns True if all dependencies were found. + """ + report() # an opening newline makes the configure output easier to read + + try: + # packaging contains the PyPA definitions of requirement specifiers. + # This is contained in a separate OS package (for example, + # python3-packaging), but it's extremely likely that the user has it + # installed already, because modern versions of pytest depend on it too. + import packaging + from packaging.requirements import Requirement + + except ImportError as err: + # We don't even have enough prerequisites to check our prerequisites. + # Print the import error as-is. + report(err) + return False + + # Strip extraneous whitespace, whole-line comments, and empty lines from our + # specifier list. + requirements = [r.strip() for r in requirements] + requirements = [r for r in requirements if r and r[0] != "#"] + + found = True + for spec in requirements: + req = Requirement(spec) + + # Skip any packages marked as unneeded for this particular Python env. + if req.marker and not req.marker.evaluate(): + continue + + # Make sure the package is installed... + version = package_version(req.name) + if version is None: + report(f"package '{req.name}': not installed") + found = False + continue + + # ...and that it has a compatible version. + if not req.specifier.contains(version): + report( + "package '{}': has version {}, but '{}' is required".format( + req.name, version, req.specifier + ), + ) + found = False + continue + + # Report installed packages too, to mirror check_modules.pl. + report(f"package '{req.name}': installed (version {version})") + + return found + + +def test_packages(requirements_file): + """ + Entry point. + """ + try: + with open(requirements_file, "r") as f: + requirements = f.readlines() + + all_found = packaging_check(requirements) + + except Exception as err: + # Surface any breakage to the configure script before failing the test. + report(err) + raise + + assert all_found, "required packages are missing" diff --git a/config/conftest.py b/config/conftest.py new file mode 100644 index 00000000000..a9c2bc546e8 --- /dev/null +++ b/config/conftest.py @@ -0,0 +1,18 @@ +# Copyright (c) 2025, PostgreSQL Global Development Group +# +# Support for check_pytest.py. The configure script provides the path to +# pytest-requirements.txt via the --requirements option added here. + +import pytest + + +def pytest_addoption(parser): + parser.addoption( + "--requirements", + help="path to pytest-requirements.txt", + ) + + +@pytest.fixture +def requirements_file(request): + return request.config.getoption("--requirements") diff --git a/config/pytest-requirements.txt b/config/pytest-requirements.txt new file mode 100644 index 00000000000..b941624b2f3 --- /dev/null +++ b/config/pytest-requirements.txt @@ -0,0 +1,21 @@ +# +# This file contains the Python packages which are required in order for us to +# enable pytest. +# +# The syntax is a *subset* of pip's requirements.txt syntax, so that both pip +# and check_pytest.py can use it. Only whole-line comments and standard Python +# dependency specifiers are allowed. pip-specific goodies like includes and +# environment substitutions are not supported; keep it simple. +# +# Packages belong here if their absence should cause a configuration failure. If +# you'd like to make a package optional, consider using pytest.importorskip() +# instead. +# + +# 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, < 9 + +# packaging is used by check_pytest.py at configure time. +packaging diff --git a/configure b/configure index 22cd866147b..aa93fa5f0aa 100755 --- a/configure +++ b/configure @@ -630,6 +630,7 @@ vpath_build PG_SYSROOT PG_VERSION_NUM LDFLAGS_EX_BE +PYTEST PROVE DBTOEPUB FOP @@ -771,6 +772,7 @@ CFLAGS CC enable_injection_points PG_TEST_EXTRA +enable_pytest enable_tap_tests enable_dtrace DTRACEFLAGS @@ -849,6 +851,7 @@ enable_profiling enable_coverage enable_dtrace enable_tap_tests +enable_pytest enable_injection_points with_blocksize with_segsize @@ -1549,7 +1552,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 @@ -3631,7 +3637,7 @@ fi # -# TAP tests +# Test frameworks # @@ -3659,6 +3665,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 # @@ -19074,6 +19106,78 @@ $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 + as_fn_error $? "pytest not found" "$LINENO" 5 + fi + { $as_echo "$as_me:${as_lineno-$LINENO}: checking for Python packages required for pytest" >&5 +$as_echo_n "checking for Python packages required for pytest... " >&6; } + modulestderr=`$PYTEST -c "$srcdir/pytest.ini" --confcutdir="$srcdir/config" --capture=no "$srcdir/config/check_pytest.py" --requirements "$srcdir/config/pytest-requirements.txt" 2>&1 >/dev/null` + if test $? -eq 0; then + echo "$modulestderr" >&5 + { $as_echo "$as_me:${as_lineno-$LINENO}: result: yes" >&5 +$as_echo "yes" >&6; } + else + { $as_echo "$as_me:${as_lineno-$LINENO}: result: $modulestderr" >&5 +$as_echo "$modulestderr" >&6; } + as_fn_error $? "Additional Python packages are required to run the pytest suites" "$LINENO" 5 + 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 e44943aa6fe..25442050f34 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)]) @@ -2415,6 +2420,22 @@ 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 + AC_MSG_ERROR([pytest not found]) + fi + AC_MSG_CHECKING(for Python packages required for pytest) + [modulestderr=`$PYTEST -c "$srcdir/pytest.ini" --confcutdir="$srcdir/config" --capture=no "$srcdir/config/check_pytest.py" --requirements "$srcdir/config/pytest-requirements.txt" 2>&1 >/dev/null`] + if test $? -eq 0; then + echo "$modulestderr" >&AS_MESSAGE_LOG_FD + AC_MSG_RESULT(yes) + else + AC_MSG_RESULT([$modulestderr]) + AC_MSG_ERROR([Additional Python packages are required to run the pytest suites]) + 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 37ed68ceeb4..06eb7a19210 100644 --- a/meson.build +++ b/meson.build @@ -1702,6 +1702,39 @@ endif +############################################################### +# Library: pytest +############################################################### + +pytest_enabled = false +pytest = not_found_dep + +pytestopt = get_option('pytest') +if not pytestopt.disabled() + pytest = find_program(get_option('PYTEST'), native: true, required: pytestopt) + if pytest.found() + pytest_check = run_command(pytest, + '-c', 'pytest.ini', + '--confcutdir=config', + '--capture=no', + 'config/check_pytest.py', + '--requirements', 'config/pytest-requirements.txt', + check: false) + if pytest_check.returncode() != 0 + message(pytest_check.stderr()) + if pytestopt.enabled() + error('Additional Python packages are required to run the pytest suites.') + else + warning('Additional Python packages are required to run the pytest suites.') + endif + else + pytest_enabled = true + endif + endif +endif + + + ############################################################### # Library: zstd ############################################################### @@ -3779,6 +3812,63 @@ 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 + + test_command = [ + pytest.full_path(), + '-c', meson.project_source_root() / 'pytest.ini', + '--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') + env.prepend('PYTHONPATH', meson.project_source_root() / 'src' / 'test' / 'pytest' / 'plugins') + + 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 @@ -3953,6 +4043,7 @@ summary( 'dtrace': dtrace, 'flex': '@0@ @1@'.format(flex.full_path(), flex_version), 'prove': prove, + 'pytest': pytest, }, section: 'Programs', ) @@ -3993,6 +4084,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/pytest.ini b/pytest.ini new file mode 100644 index 00000000000..8e8388f3afc --- /dev/null +++ b/pytest.ini @@ -0,0 +1,6 @@ +[pytest] +minversion = 7.0 + +# Ignore ./config (which contains the configure-time check_pytest.py tests) by +# default. +addopts = --ignore ./config diff --git a/src/Makefile.global.in b/src/Makefile.global.in index 0aa389bc710..8a6885206ce 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@ @@ -353,6 +354,7 @@ MSGFMT = @MSGFMT@ MSGFMT_FLAGS = @MSGFMT_FLAGS@ MSGMERGE = @MSGMERGE@ OPENSSL = @OPENSSL@ +PYTEST = @PYTEST@ PYTHON = @PYTHON@ TAR = @TAR@ XGETTEXT = @XGETTEXT@ @@ -507,6 +509,27 @@ 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." + +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/plugins:$$PYTHONPATH' \ + $(with_temp_install) \ + $(PYTEST) -c '$(abs_top_srcdir)/pytest.ini' --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 0def244c901..f68acd57bc4 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 511a72e6238..0be9771d71f 100644 --- a/src/test/Makefile +++ b/src/test/Makefile @@ -12,7 +12,16 @@ subdir = src/test top_builddir = ../.. include $(top_builddir)/src/Makefile.global -SUBDIRS = perl postmaster regress isolation modules authentication recovery subscription +SUBDIRS = \ + authentication \ + isolation \ + modules \ + perl \ + postmaster \ + pytest \ + recovery \ + regress \ + subscription ifeq ($(with_icu),yes) SUBDIRS += icu 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/plugins/pgtap.py b/src/test/pytest/plugins/pgtap.py new file mode 100644 index 00000000000..ef8291e291c --- /dev/null +++ b/src/test/pytest/plugins/pgtap.py @@ -0,0 +1,193 @@ +# Copyright (c) 2025, PostgreSQL Global Development Group + +import os +import sys +from typing import Optional + +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" + + 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/test/pytest/pyt/test_something.py b/src/test/pytest/pyt/test_something.py new file mode 100644 index 00000000000..5bd45618512 --- /dev/null +++ b/src/test/pytest/pyt/test_something.py @@ -0,0 +1,17 @@ +# Copyright (c) 2025, PostgreSQL Global Development Group + +import pytest + + +@pytest.fixture +def hey(): + yield + raise "uh-oh" + + +def test_something(hey): + assert 2 == 4 + + +def test_something_else(): + assert 2 == 2 -- 2.34.1