Skip to content

Commit

Permalink
Add custom properties support and simplify test property handling.
Browse files Browse the repository at this point in the history
- Add "PROPERTIES" option to pytest_discover_tests function to allow passing
  custom properties;
- Refactor to use lua-style long bracket syntax for serializing environment
  and custom properties, consolidating them within a single set_tests_properties
  command;
- Revert to using Pytest_ROOT instead of CMAKE_PREFIX_PATH in the integration
  document;
- Add tests and update documentation.
  • Loading branch information
buddly27 committed Oct 16, 2024
1 parent c59bc95 commit d60372f
Show file tree
Hide file tree
Showing 20 changed files with 268 additions and 108 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ jobs:
- name: Build and Run Unit Tests
shell: bash
run: |
cmake -D "CMAKE_BUILD_TYPE=Release" -S ./test -B ./test/build
cmake -S ./test -B ./test/build
cmake --build ./test/build
- name: Configure Example
Expand Down
32 changes: 14 additions & 18 deletions cmake/FindPytest.cmake
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
# This module defines the following imported targets:
# Pytest::Pytest
#
# It also exposes the 'pytest_discover_tests' function which adds ctest
# for each pytest tests. The "BUNDLE_PYTHON_TESTS" environment variable
# can be used to run all discovered tests all together.
# It also exposes the 'pytest_discover_tests' function, which adds CTest
# test for each Pytest test. The "BUNDLE_PYTHON_TESTS" environment variable
# can be used to run all discovered tests together.
#
# Usage:
# find_package(Pytest)
Expand Down Expand Up @@ -53,14 +53,15 @@ if (Pytest_FOUND AND NOT TARGET Pytest::Pytest)
PROPERTIES
IMPORTED_LOCATION "${PYTEST_EXECUTABLE}")

# Function to discover pytest tests and add them to CTest.
function(pytest_discover_tests NAME)
cmake_parse_arguments(
PARSE_ARGV 1 "" "STRIP_PARAM_BRACKETS;INCLUDE_FILE_PATH;BUNDLE_TESTS"
"WORKING_DIRECTORY;TRIM_FROM_NAME;TRIM_FROM_FULL_NAME"
"LIBRARY_PATH_PREPEND;PYTHON_PATH_PREPEND;ENVIRONMENT;DEPENDS"
"LIBRARY_PATH_PREPEND;PYTHON_PATH_PREPEND;ENVIRONMENT;PROPERTIES;DEPENDS"
)

# Identify library path environment name depending on the platform.
# Set platform-specific library path environment variable.
if (CMAKE_SYSTEM_NAME STREQUAL Windows)
set(LIBRARY_ENV_NAME PATH)
elseif(CMAKE_SYSTEM_NAME STREQUAL Darwin)
Expand All @@ -69,11 +70,11 @@ if (Pytest_FOUND AND NOT TARGET Pytest::Pytest)
set(LIBRARY_ENV_NAME LD_LIBRARY_PATH)
endif()

# Sanitize all paths for CMake.
# Convert paths to CMake-friendly format.
cmake_path(CONVERT "$ENV{${LIBRARY_ENV_NAME}}" TO_CMAKE_PATH_LIST LIBRARY_PATH)
cmake_path(CONVERT "$ENV{PYTHONPATH}" TO_CMAKE_PATH_LIST PYTHON_PATH)

# Prepend input path to environment variables.
# Prepend specified paths to the library and Python paths.
if (_LIBRARY_PATH_PREPEND)
list(REVERSE _LIBRARY_PATH_PREPEND)
foreach (_path ${_LIBRARY_PATH_PREPEND})
Expand All @@ -88,7 +89,7 @@ if (Pytest_FOUND AND NOT TARGET Pytest::Pytest)
endforeach()
endif()

# Default working directory to current build path if none is provided.
# Set default working directory if none is specified.
if (NOT _WORKING_DIRECTORY)
set(_WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR})
endif()
Expand All @@ -100,17 +101,11 @@ if (Pytest_FOUND AND NOT TARGET Pytest::Pytest)
set(_BUNDLE_TESTS $ENV{BUNDLE_PYTHON_TESTS})
endif()

# Serialize environment if necessary.
set(ENCODED_ENVIRONMENT "")
foreach(env ${_ENVIRONMENT})
string(REPLACE [[\]] [\\]] env ${env})
string(REPLACE [[;]] [\\;]] env ${env})
list(APPEND ENCODED_ENVIRONMENT ${env})
endforeach()

# Define file paths for generated CMake include files.
set(_include_file "${CMAKE_CURRENT_BINARY_DIR}/${NAME}_include.cmake")
set(_tests_file "${CMAKE_CURRENT_BINARY_DIR}/${NAME}_tests.cmake")

# Create a custom target to run the tests.
add_custom_target(
${NAME} ALL VERBATIM
BYPRODUCTS "${_tests_file}"
Expand All @@ -127,7 +122,8 @@ if (Pytest_FOUND AND NOT TARGET Pytest::Pytest)
-D "STRIP_PARAM_BRACKETS=${_STRIP_PARAM_BRACKETS}"
-D "INCLUDE_FILE_PATH=${_INCLUDE_FILE_PATH}"
-D "WORKING_DIRECTORY=${_WORKING_DIRECTORY}"
-D "ENVIRONMENT=${ENCODED_ENVIRONMENT}"
-D "ENVIRONMENT=${_ENVIRONMENT}"
-D "TEST_PROPERTIES=${_PROPERTIES}"
-D "CTEST_FILE=${_tests_file}"
-P "${CMAKE_CURRENT_FUNCTION_LIST_DIR}/PytestAddTests.cmake")

Expand All @@ -139,7 +135,7 @@ if (Pytest_FOUND AND NOT TARGET Pytest::Pytest)
"endif()\n"
)

# Add discovered tests to directory TEST_INCLUDE_FILES
# Register the include file to be processed for tests.
set_property(DIRECTORY
APPEND PROPERTY TEST_INCLUDE_FILES "${_include_file}")

Expand Down
119 changes: 58 additions & 61 deletions cmake/PytestAddTests.cmake
Original file line number Diff line number Diff line change
Expand Up @@ -3,54 +3,62 @@ cmake_minimum_required(VERSION 3.20...3.30)

if(CMAKE_SCRIPT_MODE_FILE)

# Set Cmake test file to execute each test.
# Initialize content for the CMake test file.
set(_content "")

# Convert library and Python paths to native format.
cmake_path(CONVERT "${LIBRARY_PATH}" TO_NATIVE_PATH_LIST LIBRARY_PATH)
cmake_path(CONVERT "${PYTHON_PATH}" TO_NATIVE_PATH_LIST PYTHON_PATH)

# Serialize path values separated by semicolons (required on Windows).
macro(encode_value VARIABLE_NAME)
string(REPLACE [[\]] [[\\]] ${VARIABLE_NAME} "${${VARIABLE_NAME}}")
string(REPLACE [[;]] [[\\;]] ${VARIABLE_NAME} "${${VARIABLE_NAME}}")
endmacro()

encode_value(LIBRARY_PATH)
encode_value(PYTHON_PATH)

if (BUNDLE_TESTS)
string(REPLACE [[;]] [[\\;]] LIBRARY_PATH "${LIBRARY_PATH}")
string(REPLACE [[;]] [[\\;]] PYTHON_PATH "${PYTHON_PATH}")

# Set up the encoded environment with required paths.
set(ENCODED_ENVIRONMENT
"${LIBRARY_ENV_NAME}=${LIBRARY_PATH}"
"PYTHONPATH=${PYTHON_PATH}"
)

# Serialize additional environment variables if any are provided.
foreach(env ${ENVIRONMENT})
string(REPLACE [[;]] [[\\;]] env "${env}")
list(APPEND ENCODED_ENVIRONMENT "${env}")
endforeach()

# Macro to create individual tests with optional test properties.
macro(create_test NAME IDENTIFIER)
string(APPEND _content
"add_test(\n"
" \"${TEST_GROUP_NAME}\"\n"
" \"${PYTEST_EXECUTABLE}\" \"${WORKING_DIRECTORY}\"\n"
")\n"
"set_tests_properties(\n"
" \"${TEST_GROUP_NAME}\" PROPERTIES\n"
" ENVIRONMENT \"${LIBRARY_ENV_NAME}=${LIBRARY_PATH}\"\n"
")\n"
"set_tests_properties(\n"
" \"${TEST_GROUP_NAME}\"\n"
" APPEND PROPERTIES\n"
" ENVIRONMENT \"PYTHONPATH=${PYTHON_PATH}\"\n"
")\n"
"add_test(\"${NAME}\" \"${PYTEST_EXECUTABLE}\" \"${IDENTIFIER}\")\n"
)

foreach(env ${ENVIRONMENT})
string(APPEND _content
"set_tests_properties(\n"
" \"${TEST_GROUP_NAME}\"\n"
" APPEND PROPERTIES\n"
" ENVIRONMENT ${env}\n"
")\n"
)
# Prepare the properties for the test, including the environment settings.
set(args "PROPERTIES ENVIRONMENT [==[${ENCODED_ENVIRONMENT}]==]")

# Append any additional properties, escaping complex characters if necessary.
foreach(property ${TEST_PROPERTIES})
if(property MATCHES "[^-./:a-zA-Z0-9_]")
string(APPEND args " [==[${property}]==]")
else()
string(APPEND args " ${property}")
endif()
endforeach()

# Append the test properties to the content.
string(APPEND _content "set_tests_properties(\"${NAME}\" ${args})\n")
endmacro()

# If tests are bundled together, create a single test group.
if (BUNDLE_TESTS)
create_test("${TEST_GROUP_NAME}" "${WORKING_DIRECTORY}")

else()
# Set environment for collecting tests.
# Set environment variables for collecting tests.
set(ENV{${LIBRARY_ENV_NAME}} "${LIBRARY_PATH}")
set(ENV{PYTHONPATH} "${PYTHON_PATH}")
set(ENV{PYTHONWARNINGS} "ignore")

# Collect tests.
execute_process(
COMMAND "${PYTEST_EXECUTABLE}"
--collect-only -q
Expand All @@ -61,91 +69,80 @@ if(CMAKE_SCRIPT_MODE_FILE)
WORKING_DIRECTORY ${WORKING_DIRECTORY}
)

# Check for errors during test collection.
string(REGEX MATCH "=+ ERRORS =+(.*)" _error "${_output_lines}")

if (_error)
message(${_error})
message(FATAL_ERROR "An error occurred during the collection of Python tests.")
endif()

# Convert output into list.
# Convert the collected output into a list of lines.
string(REPLACE [[;]] [[\;]] _output_lines "${_output_lines}")
string(REPLACE "\n" ";" _output_lines "${_output_lines}")

# Regex pattern to identify pytest test identifiers.
set(test_pattern "([^:]+)\.py(::([^:]+))?::([^:]+)")

foreach (line ${_output_lines})
# Iterate through each line to identify and process tests.
foreach(line ${_output_lines})
string(REGEX MATCHALL ${test_pattern} matching "${line}")

# Ignore lines not identified as a test.
# Skip lines that are not identified as tests.
if (NOT matching)
continue()
endif()

# Extract file, class, and function names from the test pattern.
set(_file ${CMAKE_MATCH_1})
set(_class ${CMAKE_MATCH_3})
set(_func ${CMAKE_MATCH_4})

# Optionally trim parts of the class or function name.
if (TRIM_FROM_NAME)
string(REGEX REPLACE "${TRIM_FROM_NAME}" "" _class "${_class}")
string(REGEX REPLACE "${TRIM_FROM_NAME}" "" _func "${_func}")
endif()

# Form the test name using class and function.
if (_class)
set(test_name "${_class}.${_func}")
else()
set(test_name "${_func}")
endif()

# Optionally strip parameter brackets from the test name.
if (STRIP_PARAM_BRACKETS)
string(REGEX REPLACE "\\[(.+)\\]$" ".\\1" test_name "${test_name}")
endif()

# Optionally include the file path in the test name.
if (INCLUDE_FILE_PATH)
cmake_path(CONVERT "${_file}" TO_CMAKE_PATH_LIST _file)
string(REGEX REPLACE "/" "." _file "${_file}")
set(test_name "${_file}.${test_name}")
endif()

# Optionally trim parts of the full test name.
if (TRIM_FROM_FULL_NAME)
string(REGEX REPLACE "${TRIM_FROM_FULL_NAME}" "" test_name "${test_name}")
endif()

# Prefix the test name with the test group name.
set(test_name "${TEST_GROUP_NAME}.${test_name}")
set(test_case "${WORKING_DIRECTORY}/${line}")

string(APPEND _content
"add_test(\n"
" \"${test_name}\"\n"
" \"${PYTEST_EXECUTABLE}\" \"${test_case}\"\n"
")\n"
"set_tests_properties(\n"
" \"${test_name}\" PROPERTIES\n"
" ENVIRONMENT \"${LIBRARY_ENV_NAME}=${LIBRARY_PATH}\"\n"
")\n"
"set_tests_properties(\n"
" \"${test_name}\"\n"
" APPEND PROPERTIES\n"
" ENVIRONMENT \"PYTHONPATH=${PYTHON_PATH}\"\n"
")\n"
)

foreach(env ${ENVIRONMENT})
string(APPEND _content
"set_tests_properties(\n"
" \"${test_name}\"\n"
" APPEND PROPERTIES\n"
" ENVIRONMENT ${env}\n"
")\n"
)
endforeach()

# Create the test for CTest.
create_test("${test_name}" "${test_case}")
endforeach()

# Warn if no tests were discovered.
if(NOT _content)
message(WARNING "No Python tests have been discovered.")
endif()
endif()

# Write the generated test content to the specified CTest file.
file(WRITE ${CTEST_FILE} ${_content})

endif()
14 changes: 14 additions & 0 deletions doc/api_reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ API Reference
[LIBRARY_PATH_PREPEND path1 path2...]
[PYTHON_PATH_PREPEND path1 path2...]
[ENVIRONMENT env1 env2...]
[PROPERTIES prop1 prop2...]
[DEPENDS target1 target2...]
[INCLUDE_FILE_PATH]
[STRIP_PARAM_BRACKETS]
Expand Down Expand Up @@ -129,6 +130,19 @@ API Reference
"ENV_VAR3=VALUE3"
)

* ``PROPERTIES``

List of custom `test properties
<https://cmake.org/cmake/help/latest/manual/cmake-properties.7.html#test-properties>`_
to apply for all generated tests::

pytest_discover_tests(
...
PROPERTIES
LABELS "python;unit"
TIMEOUT 120
)

* ``DEPENDS``

List of dependent targets that need to be executed before running
Expand Down
4 changes: 2 additions & 2 deletions doc/integration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,10 @@ able to discover the newly installed configuration automatically using its
option should not be set to False.

When using a Python virtual environment, or if Python is installed in a
non-standard location, the :envvar:`Pytest_ROOT` environment variable
non-standard location, the :envvar:`CMAKE_PREFIX_PATH` environment variable
(or :term:`CMake` option) can be used to guide the discovery process::

cmake -S . -B ./build -D "Pytest_ROOT=/path/to/python/prefix"
cmake -S . -B ./build -D "CMAKE_PREFIX_PATH=/path/to/python/prefix"

This is also necessary when installing the configuration in the
`Python user directory
Expand Down
19 changes: 11 additions & 8 deletions doc/release/release_notes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,27 @@
Release Notes
*************

.. release:: Upcoming

.. change:: new

Added ``PROPERTIES`` option to the :func:`pytest_discover_tests`
function, providing custom `test properties
<https://cmake.org/cmake/help/latest/manual/cmake-properties.7.html#test-properties>`_
for all generated tests.

.. release:: 0.10.0
:date: 2024-10-11

.. change:: new

Added ``INCLUDE_FILE_PATH`` option to the :func:`pytest_discover_tests`
function use the file path to compute the test identifier.

.. seealso:: :ref:`tutorial/function`
function, allowing the file path to be included in the test identifier.

.. change:: new

Added ``TRIM_FROM_FULL_NAME`` option to the :func:`pytest_discover_tests`
function trim parts of the full test name generated.

.. seealso:: :ref:`tutorial/function`
function, enabling parts of the full test name to be trimmed.

.. change:: fixed

Expand All @@ -40,8 +45,6 @@ Release Notes
Added ``STRIP_PARAM_BRACKETS`` option to the :func:`pytest_discover_tests`
function to strip square brackets used for :term:`parametrizing tests`.

.. seealso:: :ref:`tutorial/function`

.. release:: 0.8.4
:date: 2024-10-06

Expand Down
Loading

0 comments on commit d60372f

Please sign in to comment.