diff --git a/.actrc b/.actrc new file mode 100644 index 00000000..10685d4c --- /dev/null +++ b/.actrc @@ -0,0 +1 @@ +-P ubuntu-latest=catthehacker/ubuntu:act-latest \ No newline at end of file diff --git a/.github/actions/generate-build-matrix/generate_matrix.py b/.github/actions/generate-build-matrix/generate_matrix.py index b6d5932e..a7d7e9f7 100644 --- a/.github/actions/generate-build-matrix/generate_matrix.py +++ b/.github/actions/generate-build-matrix/generate_matrix.py @@ -1,4 +1,5 @@ """Generates a build matrix for CI based on the trigger event and user input.""" + import json import os import re diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 61dadf28..e8b142fb 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -99,3 +99,58 @@ All Markdown files must strictly follow these markdownlint rules: - **MD034**: No bare URLs (for example, use a markdown link like `[text](destination)` instead of a plain URL) - **MD036**: Use # headings, not **Bold:** for titles - **MD040**: Always specify code block language (for example, use '```bash', '```python', '```text', etc.) + +## Development & Testing Workflows + +### Build and Test + +- **Environment**: Always source `setup-env.sh` before building or testing. This applies to all environments (Dev Container, local machine, HPC). +- **Configuration**: + - **Presets**: Prefer `CMakePresets.json` workflows (e.g., `cmake --preset default`). + - **Generator**: Prefer `Ninja` over `Makefiles` when available (`-G Ninja`). +- **Build**: + - **Parallelism**: Always use multiple cores. Ninja does this by default. For `make`, use `cmake --build build -j $(nproc)`. +- **Test**: + - **Parallelism**: Run tests in parallel using `ctest -j $(nproc)` or `ctest --parallel `. + - **Selection**: Run specific tests with `ctest -R "regex"` (e.g., `ctest -R "py:*"`). + - **Debugging**: Use `ctest --output-on-failure` to see logs for failed tests. + +### Python Integration + +- **Naming**: Avoid naming Python test scripts `types.py` or other names that shadow standard library modules. This causes obscure import errors (e.g., `ModuleNotFoundError: No module named 'numpy'`). +- **PYTHONPATH**: When running tests in Spack environments, ensure `PYTHONPATH` includes `site-packages`. In CMake, explicitly add `Python_SITELIB` and `Python_SITEARCH` to `TEST_PYTHONPATH`. +- **Test Structure**: + - **C++ Driver**: Provides data streams (e.g., `test/python/driver.cpp`). + - **Jsonnet Config**: Wires the graph (e.g., `test/python/pytypes.jsonnet`). + - **Python Script**: Implements algorithms (e.g., `test/python/test_types.py`). +- **Type Conversion**: `plugins/python/src/modulewrap.cpp` handles C++ $\leftrightarrow$ Python conversion. + - **Mechanism**: Uses string comparison of type names (e.g., `"float64]]"`). This is brittle. + - **Requirement**: Ensure converters exist for all types used in tests (e.g., `float`, `double`, `unsigned int`, and their vector equivalents). + - **Warning**: Exact type matches are required. `numpy.float32` != `float`. + +### Coverage Analysis + +- **Tooling**: The project uses LLVM source-based coverage. +- **Requirement**: The `phlex` binary must catch exceptions in `main` to ensure coverage data is flushed to disk even when tests fail/crash. +- **Generation**: + - **CMake Targets**: `coverage-xml`, `coverage-html` (if configured). + - **Manual**: + 1. Run tests with `LLVM_PROFILE_FILE` set (e.g., `export LLVM_PROFILE_FILE="profraw/%m-%p.profraw"`). + 2. Merge profiles: `llvm-profdata merge -sparse profraw/*.profraw -o coverage.profdata`. + 3. Generate report: `llvm-cov show -instr-profile=coverage.profdata -format=html ...` + +### Local GitHub Actions Testing (`act`) + +- **Tool**: Use `act` to run GitHub Actions workflows locally. +- **Configuration**: Ensure `.actrc` exists in the workspace root with the following content to use a compatible runner image: + ```text + -P ubuntu-latest=catthehacker/ubuntu:act-latest + ``` +- **Usage**: + - List jobs: `act -l` + - Run specific job: `act -j ` (e.g., `act -j python-check`) + - Run specific event: `act pull_request` +- **Troubleshooting**: + - **Docker Socket**: `act` requires access to the Docker socket. In dev containers, this may require specific mount configurations or permissions. + - **Artifacts**: `act` creates a `phlex-src` directory (or similar) for checkout. Ensure this is cleaned up or ignored by tools like `mypy`. + diff --git a/CMakeLists.txt b/CMakeLists.txt index 03814efa..fb9f39c3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -68,12 +68,12 @@ option(ENABLE_CLANG_TIDY "Enable clang-tidy checks during build" OFF) add_compile_options(-Wall -Werror -Wunused -Wunused-parameter -pedantic) if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU") - if(CMAKE_CXX_COMPILER_VERSION VERSION_GREATER_EQUAL "14.1" - AND CMAKE_COMPILER_VERSION VERSION_LESS "15" - ) - # GCC 14.1 issues many false positives re. array-bounds and + if(CMAKE_CXX_COMPILER_VERSION VERSION_GREATER_EQUAL "14.1") + # GCC 14.1+ issues many false positives re. array-bounds and # stringop-overflow - add_compile_options(-Wno-array-bounds -Wno-stringop-overflow) + add_compile_options( + -Wno-array-bounds -Wno-stringop-overflow -Wno-maybe-uninitialized + ) endif() endif() @@ -104,7 +104,8 @@ if(ENABLE_TSAN) -g -O1 # Ensure no optimizations interfere with TSan - "$<$:-fno-omit-frame-pointer -fno-optimize-sibling-calls>" + "$<$:-fno-omit-frame-pointer>" + "$<$:-fno-optimize-sibling-calls>" ) add_link_options(-fsanitize=thread) else() @@ -128,7 +129,8 @@ if(ENABLE_ASAN) -g -O1 # Ensure no optimizations interfere with ASan - "$<$:-fno-omit-frame-pointer -fno-optimize-sibling-calls>" + "$<$:-fno-omit-frame-pointer>" + "$<$:-fno-optimize-sibling-calls>" ) add_link_options(-fsanitize=address) else() diff --git a/ci/Dockerfile b/ci/Dockerfile index a96bd826..6b753aff 100644 --- a/ci/Dockerfile +++ b/ci/Dockerfile @@ -232,7 +232,7 @@ set -euo pipefail # Use pip to install tooling that is not packaged in Spack . /entrypoint.sh -PYTHONDONTWRITEBYTECODE=1 pip --isolated --no-input --disable-pip-version-check --no-cache-dir install --prefix /usr/local ruff +PYTHONDONTWRITEBYTECODE=1 pip --isolated --no-input --disable-pip-version-check --no-cache-dir install --prefix /usr/local ruff mypy rm -rf ~/.spack INSTALL_PYTHON_TOOLS diff --git a/plugins/python/CMakeLists.txt b/plugins/python/CMakeLists.txt index f43e12c9..33573f1c 100644 --- a/plugins/python/CMakeLists.txt +++ b/plugins/python/CMakeLists.txt @@ -14,13 +14,20 @@ if(Python_FOUND) ${Python_EXECUTABLE} -c "import sys try: import ${MODULE_NAME} - from packaging.version import parse as parse_version installed_version = getattr(${MODULE_NAME}, '__version__', None) - if parse_version(installed_version) >= parse_version('${MIN_VERSION}'): + if not installed_version: + sys.exit(2) + + def parse(v): + return tuple(map(int, v.split('.')[:3])) + + if parse(installed_version) >= parse('${MIN_VERSION}'): sys.exit(0) else: sys.exit(2) # Version too low except ImportError: + sys.exit(1) +except Exception: sys.exit(1)" RESULT_VARIABLE _module_check_result ) diff --git a/plugins/python/README.md b/plugins/python/README.md new file mode 100644 index 00000000..e84525e7 --- /dev/null +++ b/plugins/python/README.md @@ -0,0 +1,55 @@ +# Phlex Python Plugin Architecture + +This directory contains the C++ source code for the Phlex Python plugin, which enables Phlex to execute Python code as part of its computation graph. + +## Architecture Overview + +The integration is built on the **Python C API** (not `pybind11`) to maintain strict control over the interpreter lifecycle and memory management. + +### 1. The "Type Bridge" (`modulewrap.cpp`) + +The core of the integration is the type conversion layer in `src/modulewrap.cpp`. This layer is responsible for: +- Converting Phlex `Product` objects (C++) into Python objects (e.g., `PyObject*`, `numpy.ndarray`). +- Converting Python return values back into Phlex `Product` objects. + +**Critical Implementation Detail:** +The type mapping relies on **string comparison** of type names. +- **Mechanism**: The C++ code checks `type_name() == "float64]]"` to identify a 2D array of doubles. +- **Brittleness**: This is a fragile contract. If the type name changes (e.g., `numpy` changes its string representation) or if a user provides a slightly different type (e.g., `float` vs `np.float32`), the bridge may fail. +- **Extension**: When adding support for new types, you must explicitly add converters in `modulewrap.cpp` for both scalar and vector/array versions. + +### 2. Hybrid Configuration + +Phlex uses a hybrid configuration model involving three languages: + +1. **Jsonnet** (`*.jsonnet`): Defines the computation graph structure. It specifies: + - The nodes in the graph. + - The Python module/class to load for specific nodes. + - Configuration parameters passed to the Python object. +2. **C++ Driver**: The executable that: + - Parses the Jsonnet configuration. + - Initializes the Phlex core. + - Loads the Python interpreter and the specified plugin. +3. **Python Code** (`*.py`): Implements the algorithmic logic. + +### 3. Environment & Testing + +Because the Python interpreter is embedded within the C++ application, the runtime environment is critical. + +- **PYTHONPATH**: Must be set correctly to include: + - The build directory (for generated modules). + - The source directory (for user scripts). + - System/Spack `site-packages` (for dependencies like `numpy`). +- **Naming Collisions**: + - **Warning**: Do not name test files `types.py`, `test.py`, `code.py`, or other names that shadow standard library modules. + - **Consequence**: Shadowing can cause obscure failures in internal libraries (e.g., `numpy` failing to import because it tries to import `types` from the standard library but gets your local file instead). + +## Development Guidelines + +1. **Adding New Types**: + - Update `src/modulewrap.cpp` to handle the new C++ type. + - Add a corresponding test case in `test/python/` to verify the round-trip conversion. +2. **Testing**: + - Use `ctest` to run tests. + - Tests are integration tests: they run the full C++ application which loads the Python script. + - Debugging: Use `ctest --output-on-failure` to see Python exceptions. diff --git a/plugins/python/src/lifelinewrap.cpp b/plugins/python/src/lifelinewrap.cpp index 0f81e6bb..e4893480 100644 --- a/plugins/python/src/lifelinewrap.cpp +++ b/plugins/python/src/lifelinewrap.cpp @@ -31,9 +31,11 @@ static int ll_clear(py_lifeline_t* pyobj) static void ll_dealloc(py_lifeline_t* pyobj) { + PyObject_GC_UnTrack(pyobj); Py_CLEAR(pyobj->m_view); typedef std::shared_ptr generic_shared_t; pyobj->m_source.~generic_shared_t(); + Py_TYPE(pyobj)->tp_free((PyObject*)pyobj); } // clang-format off diff --git a/plugins/python/src/modulewrap.cpp b/plugins/python/src/modulewrap.cpp index 65109e71..22dd06a3 100644 --- a/plugins/python/src/modulewrap.cpp +++ b/plugins/python/src/modulewrap.cpp @@ -2,11 +2,15 @@ #include "wrap.hpp" #include +#include #include +#include #include #include #include +// static std::mutex g_py_mutex; + #ifdef PHLEX_HAVE_NUMPY #define NO_IMPORT_ARRAY #define PY_ARRAY_UNIQUE_SYMBOL phlex_ARRAY_API @@ -15,6 +19,17 @@ using namespace phlex::experimental; +struct PyObjectDeleter { + void operator()(PyObject* p) const + { + if (p) { + phlex::experimental::PyGILRAII gil; + Py_DECREF(p); + } + } +}; +using PyObjectPtr = std::shared_ptr; + // TODO: the layer is currently hard-wired and should come from the product // specification instead, but that doesn't exist in Python yet. static std::string const LAYER = "job"; @@ -55,12 +70,12 @@ namespace { return oss.str(); } - static inline PyObject* lifeline_transform(intptr_t arg) + static inline PyObject* lifeline_transform(PyObject* arg) { - if (Py_TYPE((PyObject*)arg) == &PhlexLifeline_Type) { + if (Py_TYPE(arg) == &PhlexLifeline_Type) { return ((py_lifeline_t*)arg)->m_view; } - return (PyObject*)arg; + return arg; } // callable object managing the callback @@ -84,18 +99,20 @@ namespace { Py_INCREF(pc.m_callable); m_callable = pc.m_callable; } + return *this; } ~py_callback() { Py_DECREF(m_callable); } template - intptr_t call(Args... args) + PyObjectPtr call(Args... args) { static_assert(sizeof...(Args) == N, "Argument count mismatch"); PyGILRAII gil; + // std::lock_guard lock(g_py_mutex); - PyObject* result = - PyObject_CallFunctionObjArgs((PyObject*)m_callable, lifeline_transform(args)..., nullptr); + PyObject* result = PyObject_CallFunctionObjArgs( + (PyObject*)m_callable, lifeline_transform(args.get())..., nullptr); std::string error_msg; if (!result) { @@ -103,12 +120,10 @@ namespace { error_msg = "Unknown python error"; } - decref_all(args...); - if (!error_msg.empty()) throw std::runtime_error(error_msg.c_str()); - return (intptr_t)result; + return PyObjectPtr(result, PyObjectDeleter()); } template @@ -117,9 +132,10 @@ namespace { static_assert(sizeof...(Args) == N, "Argument count mismatch"); PyGILRAII gil; + // std::lock_guard lock(g_py_mutex); PyObject* result = - PyObject_CallFunctionObjArgs((PyObject*)m_callable, (PyObject*)args..., nullptr); + PyObject_CallFunctionObjArgs((PyObject*)m_callable, (PyObject*)args.get()..., nullptr); std::string error_msg; if (!result) { @@ -128,48 +144,41 @@ namespace { } else Py_DECREF(result); - decref_all(args...); - if (!error_msg.empty()) throw std::runtime_error(error_msg.c_str()); } - - private: - template - void decref_all(Args... args) - { - // helper to decrement reference counts of N arguments - (Py_DECREF((PyObject*)args), ...); - } }; // use explicit instatiations to ensure that the function signature can // be derived by the graph builder struct py_callback_1 : public py_callback<1> { - intptr_t operator()(intptr_t arg0) { return call(arg0); } + PyObjectPtr operator()(PyObjectPtr arg0) { return call(arg0); } }; struct py_callback_2 : public py_callback<2> { - intptr_t operator()(intptr_t arg0, intptr_t arg1) { return call(arg0, arg1); } + PyObjectPtr operator()(PyObjectPtr arg0, PyObjectPtr arg1) { return call(arg0, arg1); } }; struct py_callback_3 : public py_callback<3> { - intptr_t operator()(intptr_t arg0, intptr_t arg1, intptr_t arg2) + PyObjectPtr operator()(PyObjectPtr arg0, PyObjectPtr arg1, PyObjectPtr arg2) { return call(arg0, arg1, arg2); } }; struct py_callback_1v : public py_callback<1> { - void operator()(intptr_t arg0) { callv(arg0); } + void operator()(PyObjectPtr arg0) { callv(arg0); } }; struct py_callback_2v : public py_callback<2> { - void operator()(intptr_t arg0, intptr_t arg1) { callv(arg0, arg1); } + void operator()(PyObjectPtr arg0, PyObjectPtr arg1) { callv(arg0, arg1); } }; struct py_callback_3v : public py_callback<3> { - void operator()(intptr_t arg0, intptr_t arg1, intptr_t arg2) { callv(arg0, arg1, arg2); } + void operator()(PyObjectPtr arg0, PyObjectPtr arg1, PyObjectPtr arg2) + { + callv(arg0, arg1, arg2); + } }; static std::vector cseq(PyObject* coll) @@ -220,7 +229,7 @@ namespace { // for numpy typing, there's no useful way of figuring out the type from the // name of the type, only from its string representation, so fall through and // let this method return str() - if (ann != "ndarray") + if (ann != "ndarray" && ann != "list") return ann; // start over for numpy type using result from str() @@ -292,17 +301,16 @@ namespace { } #define BASIC_CONVERTER(name, cpptype, topy, frompy) \ - static intptr_t name##_to_py(cpptype a) \ + static PyObjectPtr name##_to_py(cpptype a) \ { \ PyGILRAII gil; \ - return (intptr_t)topy(a); \ + return PyObjectPtr(topy(a), PyObjectDeleter()); \ } \ \ - static cpptype py_to_##name(intptr_t pyobj) \ + static cpptype py_to_##name(PyObjectPtr pyobj) \ { \ PyGILRAII gil; \ - cpptype i = (cpptype)frompy((PyObject*)pyobj); \ - Py_DECREF((PyObject*)pyobj); \ + cpptype i = (cpptype)frompy(pyobj.get()); \ return i; \ } @@ -310,13 +318,24 @@ namespace { BASIC_CONVERTER(int, int, PyLong_FromLong, PyLong_AsLong) BASIC_CONVERTER(uint, unsigned int, PyLong_FromLong, pylong_or_int_as_ulong) BASIC_CONVERTER(long, long, PyLong_FromLong, pylong_as_strictlong) - BASIC_CONVERTER(ulong, unsigned long, PyLong_FromUnsignedLong, pylong_or_int_as_ulong) + // BASIC_CONVERTER(ulong, unsigned long, PyLong_FromUnsignedLong, pylong_or_int_as_ulong) + static PyObjectPtr ulong_to_py(unsigned long a) + { + PyGILRAII gil; + return PyObjectPtr(PyLong_FromUnsignedLong(a), PyObjectDeleter()); + } + static unsigned long py_to_ulong(PyObjectPtr pyobj) + { + PyGILRAII gil; + unsigned long i = (unsigned long)pylong_or_int_as_ulong(pyobj.get()); + return i; + } BASIC_CONVERTER(float, float, PyFloat_FromDouble, PyFloat_AsDouble) BASIC_CONVERTER(double, double, PyFloat_FromDouble, PyFloat_AsDouble) #ifdef PHLEX_HAVE_NUMPY #define VECTOR_CONVERTER(name, cpptype, nptype) \ - static intptr_t name##_to_py(std::shared_ptr> const& v) \ + static PyObjectPtr name##_to_py(std::shared_ptr> const& v) \ { \ PyGILRAII gil; \ \ @@ -331,7 +350,7 @@ namespace { ); \ \ if (!np_view) \ - return (intptr_t)nullptr; \ + return PyObjectPtr(); \ \ /* make the data read-only by not making it writable */ \ PyArray_CLEARFLAGS((PyArrayObject*)np_view, NPY_ARRAY_WRITEABLE); \ @@ -341,34 +360,121 @@ namespace { /* when passing it to the registered Python function */ \ py_lifeline_t* pyll = \ (py_lifeline_t*)PhlexLifeline_Type.tp_new(&PhlexLifeline_Type, nullptr, nullptr); \ + new (&pyll->m_source) std::shared_ptr(v); \ pyll->m_view = np_view; /* steals reference */ \ - pyll->m_source = v; \ \ - return (intptr_t)pyll; \ + return PyObjectPtr((PyObject*)pyll, PyObjectDeleter()); \ } - VECTOR_CONVERTER(vint, int, NPY_INT) - VECTOR_CONVERTER(vuint, unsigned int, NPY_UINT) - VECTOR_CONVERTER(vlong, long, NPY_LONG) - VECTOR_CONVERTER(vulong, unsigned long, NPY_ULONG) + // VECTOR_CONVERTER(vint, int, NPY_INT) + static PyObjectPtr vint_to_py(std::shared_ptr> const& v) + { + PyGILRAII gil; + // std::lock_guard lock(g_py_mutex); + if (!v) + return PyObjectPtr(); + PyObject* list = PyList_New(v->size()); + if (!list) { + PyErr_Print(); + return PyObjectPtr(); + } + for (size_t i = 0; i < v->size(); ++i) { + PyObject* item = PyLong_FromLong((*v)[i]); + if (!item) { + PyErr_Print(); + Py_DECREF(list); + return PyObjectPtr(); + } + PyList_SET_ITEM(list, i, item); + } + return PyObjectPtr(list, PyObjectDeleter()); + } + // VECTOR_CONVERTER(vuint, unsigned int, NPY_UINT) + static PyObjectPtr vuint_to_py(std::shared_ptr> const& v) + { + PyGILRAII gil; + // std::lock_guard lock(g_py_mutex); + if (!v) + return PyObjectPtr(); + PyObject* list = PyList_New(v->size()); + if (!list) { + PyErr_Print(); + return PyObjectPtr(); + } + for (size_t i = 0; i < v->size(); ++i) { + PyObject* item = PyLong_FromUnsignedLong((*v)[i]); + if (!item) { + PyErr_Print(); + Py_DECREF(list); + return PyObjectPtr(); + } + PyList_SET_ITEM(list, i, item); + } + return PyObjectPtr(list, PyObjectDeleter()); + } + // VECTOR_CONVERTER(vlong, long, NPY_LONG) + static PyObjectPtr vlong_to_py(std::shared_ptr> const& v) + { + PyGILRAII gil; + // std::lock_guard lock(g_py_mutex); + if (!v) + return PyObjectPtr(); + PyObject* list = PyList_New(v->size()); + if (!list) { + PyErr_Print(); + return PyObjectPtr(); + } + for (size_t i = 0; i < v->size(); ++i) { + PyObject* item = PyLong_FromLong((*v)[i]); + if (!item) { + PyErr_Print(); + Py_DECREF(list); + return PyObjectPtr(); + } + PyList_SET_ITEM(list, i, item); + } + return PyObjectPtr(list, PyObjectDeleter()); + } + // VECTOR_CONVERTER(vulong, unsigned long, NPY_ULONG) + static PyObjectPtr vulong_to_py(std::shared_ptr> const& v) + { + PyGILRAII gil; + // std::lock_guard lock(g_py_mutex); + if (!v) + return PyObjectPtr(); + PyObject* list = PyList_New(v->size()); + if (!list) { + PyErr_Print(); + return PyObjectPtr(); + } + for (size_t i = 0; i < v->size(); ++i) { + PyObject* item = PyLong_FromUnsignedLong((*v)[i]); + if (!item) { + PyErr_Print(); + Py_DECREF(list); + return PyObjectPtr(); + } + PyList_SET_ITEM(list, i, item); + } + return PyObjectPtr(list, PyObjectDeleter()); + } VECTOR_CONVERTER(vfloat, float, NPY_FLOAT) VECTOR_CONVERTER(vdouble, double, NPY_DOUBLE) #define NUMPY_ARRAY_CONVERTER(name, cpptype, nptype) \ - static std::shared_ptr> py_to_##name(intptr_t pyobj) \ + static std::shared_ptr> py_to_##name(PyObjectPtr pyobj) \ { \ PyGILRAII gil; \ \ auto vec = std::make_shared>(); \ \ /* TODO: because of unresolved ownership issues, copy the full array contents */ \ - if (!pyobj || !PyArray_Check((PyObject*)pyobj)) { \ + if (!pyobj || !PyArray_Check(pyobj.get())) { \ PyErr_Clear(); /* how to report an error? */ \ - Py_DECREF((PyObject*)pyobj); \ return vec; \ } \ \ - PyArrayObject* arr = (PyArrayObject*)pyobj; \ + PyArrayObject* arr = (PyArrayObject*)pyobj.get(); \ \ /* TODO: flattening the array here seems to be the only workable solution */ \ npy_intp* dims = PyArray_DIMS(arr); \ @@ -382,16 +488,249 @@ namespace { vec->reserve(total); \ vec->insert(vec->end(), raw, raw + total); \ \ - Py_DECREF((PyObject*)pyobj); \ return vec; \ } - NUMPY_ARRAY_CONVERTER(vint, int, NPY_INT) - NUMPY_ARRAY_CONVERTER(vuint, unsigned int, NPY_UINT) - NUMPY_ARRAY_CONVERTER(vlong, long, NPY_LONG) - NUMPY_ARRAY_CONVERTER(vulong, unsigned long, NPY_ULONG) - NUMPY_ARRAY_CONVERTER(vfloat, float, NPY_FLOAT) - NUMPY_ARRAY_CONVERTER(vdouble, double, NPY_DOUBLE) + // NUMPY_ARRAY_CONVERTER(vint, int, NPY_INT) + static std::shared_ptr> py_to_vint(PyObjectPtr pyobj) + { + PyGILRAII gil; + // std::lock_guard lock(g_py_mutex); + auto vec = std::make_shared>(); + PyObject* obj = pyobj.get(); + + if (obj) { + if (PyList_Check(obj)) { + size_t size = PyList_Size(obj); + vec->reserve(size); + for (size_t i = 0; i < size; ++i) { + PyObject* item = PyList_GetItem(obj, i); + if (!item) { + PyErr_Print(); + break; + } + long val = PyLong_AsLong(item); + if (PyErr_Occurred()) { + PyErr_Print(); + break; + } + vec->push_back((int)val); + } + } else if (PyArray_Check(obj)) { + PyArrayObject* arr = (PyArrayObject*)obj; + npy_intp* dims = PyArray_DIMS(arr); + int nd = PyArray_NDIM(arr); + size_t total = 1; + for (int i = 0; i < nd; ++i) + total *= static_cast(dims[i]); + + int* raw = static_cast(PyArray_DATA(arr)); + vec->reserve(total); + vec->insert(vec->end(), raw, raw + total); + } + } + return vec; + } + // NUMPY_ARRAY_CONVERTER(vuint, unsigned int, NPY_UINT) + static std::shared_ptr> py_to_vuint(PyObjectPtr pyobj) + { + PyGILRAII gil; + // std::lock_guard lock(g_py_mutex); + auto vec = std::make_shared>(); + PyObject* obj = pyobj.get(); + + if (obj) { + if (PyList_Check(obj)) { + size_t size = PyList_Size(obj); + vec->reserve(size); + for (size_t i = 0; i < size; ++i) { + PyObject* item = PyList_GetItem(obj, i); + if (!item) { + PyErr_Print(); + break; + } + unsigned long val = PyLong_AsUnsignedLong(item); + if (PyErr_Occurred()) { + PyErr_Print(); + break; + } + vec->push_back((unsigned int)val); + } + } else if (PyArray_Check(obj)) { + PyArrayObject* arr = (PyArrayObject*)obj; + npy_intp* dims = PyArray_DIMS(arr); + int nd = PyArray_NDIM(arr); + size_t total = 1; + for (int i = 0; i < nd; ++i) + total *= static_cast(dims[i]); + + unsigned int* raw = static_cast(PyArray_DATA(arr)); + vec->reserve(total); + vec->insert(vec->end(), raw, raw + total); + } + } + return vec; + } + // NUMPY_ARRAY_CONVERTER(vlong, long, NPY_LONG) + static std::shared_ptr> py_to_vlong(PyObjectPtr pyobj) + { + PyGILRAII gil; + // std::lock_guard lock(g_py_mutex); + auto vec = std::make_shared>(); + PyObject* obj = pyobj.get(); + + if (obj) { + if (PyList_Check(obj)) { + size_t size = PyList_Size(obj); + vec->reserve(size); + for (size_t i = 0; i < size; ++i) { + PyObject* item = PyList_GetItem(obj, i); + if (!item) { + PyErr_Print(); + break; + } + long val = PyLong_AsLong(item); + if (PyErr_Occurred()) { + PyErr_Print(); + break; + } + vec->push_back(val); + } + } else if (PyArray_Check(obj)) { + PyArrayObject* arr = (PyArrayObject*)obj; + npy_intp* dims = PyArray_DIMS(arr); + int nd = PyArray_NDIM(arr); + size_t total = 1; + for (int i = 0; i < nd; ++i) + total *= static_cast(dims[i]); + + long* raw = static_cast(PyArray_DATA(arr)); + vec->reserve(total); + vec->insert(vec->end(), raw, raw + total); + } + } + return vec; + } + // NUMPY_ARRAY_CONVERTER(vulong, unsigned long, NPY_ULONG) + static std::shared_ptr> py_to_vulong(PyObjectPtr pyobj) + { + PyGILRAII gil; + // std::lock_guard lock(g_py_mutex); + auto vec = std::make_shared>(); + PyObject* obj = pyobj.get(); + + if (obj) { + if (PyList_Check(obj)) { + size_t size = PyList_Size(obj); + vec->reserve(size); + for (size_t i = 0; i < size; ++i) { + PyObject* item = PyList_GetItem(obj, i); + if (!item) { + PyErr_Print(); + break; + } + unsigned long val = PyLong_AsUnsignedLong(item); + if (PyErr_Occurred()) { + PyErr_Print(); + break; + } + vec->push_back(val); + } + } else if (PyArray_Check(obj)) { + PyArrayObject* arr = (PyArrayObject*)obj; + npy_intp* dims = PyArray_DIMS(arr); + int nd = PyArray_NDIM(arr); + size_t total = 1; + for (int i = 0; i < nd; ++i) + total *= static_cast(dims[i]); + + unsigned long* raw = static_cast(PyArray_DATA(arr)); + vec->reserve(total); + vec->insert(vec->end(), raw, raw + total); + } + } + return vec; + } + // NUMPY_ARRAY_CONVERTER(vfloat, float, NPY_FLOAT) + static std::shared_ptr> py_to_vfloat(PyObjectPtr pyobj) + { + PyGILRAII gil; + // std::lock_guard lock(g_py_mutex); + auto vec = std::make_shared>(); + PyObject* obj = pyobj.get(); + + if (obj) { + if (PyList_Check(obj)) { + size_t size = PyList_Size(obj); + vec->reserve(size); + for (size_t i = 0; i < size; ++i) { + PyObject* item = PyList_GetItem(obj, i); + if (!item) { + PyErr_Print(); + break; + } + double val = PyFloat_AsDouble(item); + if (PyErr_Occurred()) { + PyErr_Print(); + break; + } + vec->push_back((float)val); + } + } else if (PyArray_Check(obj)) { + PyArrayObject* arr = (PyArrayObject*)obj; + npy_intp* dims = PyArray_DIMS(arr); + int nd = PyArray_NDIM(arr); + size_t total = 1; + for (int i = 0; i < nd; ++i) + total *= static_cast(dims[i]); + + float* raw = static_cast(PyArray_DATA(arr)); + vec->reserve(total); + vec->insert(vec->end(), raw, raw + total); + } + } + return vec; + } + // NUMPY_ARRAY_CONVERTER(vdouble, double, NPY_DOUBLE) + static std::shared_ptr> py_to_vdouble(PyObjectPtr pyobj) + { + PyGILRAII gil; + // std::lock_guard lock(g_py_mutex); + auto vec = std::make_shared>(); + PyObject* obj = pyobj.get(); + + if (obj) { + if (PyList_Check(obj)) { + size_t size = PyList_Size(obj); + vec->reserve(size); + for (size_t i = 0; i < size; ++i) { + PyObject* item = PyList_GetItem(obj, i); + if (!item) { + PyErr_Print(); + break; + } + double val = PyFloat_AsDouble(item); + if (PyErr_Occurred()) { + PyErr_Print(); + break; + } + vec->push_back(val); + } + } else if (PyArray_Check(obj)) { + PyArrayObject* arr = (PyArrayObject*)obj; + npy_intp* dims = PyArray_DIMS(arr); + int nd = PyArray_NDIM(arr); + size_t total = 1; + for (int i = 0; i < nd; ++i) + total *= static_cast(dims[i]); + + double* raw = static_cast(PyArray_DATA(arr)); + vec->reserve(total); + vec->insert(vec->end(), raw, raw + total); + } + } + return vec; + } #endif } // unnamed namespace @@ -491,12 +830,18 @@ static PyObject* parse_args(PyObject* args, // dictionary is ordered with return last if provide (note: the keys here // could be used as input labels, instead of the ones from the configuration, // but that is probably not practical in actual use, so they are ignored) - PyObject* values = PyDict_Values(annot); - for (Py_ssize_t i = 0; i < (PyList_GET_SIZE(values) - (ret ? 1 : 0)); ++i) { - PyObject* item = PyList_GET_ITEM(values, i); - input_types.push_back(annotation_as_text(item)); + + // Re-implementing robust annotation extraction + PyObject *key, *val; + Py_ssize_t pos = 0; + + while (PyDict_Next(annot, &pos, &key, &val)) { + // Skip 'return' annotation as it is handled separately + if (PyUnicode_Check(key) && PyUnicode_CompareWithASCIIString(key, "return") == 0) { + continue; + } + input_types.push_back(annotation_as_text(val)); } - Py_DECREF(values); } Py_XDECREF(annot); @@ -559,32 +904,32 @@ static bool insert_input_converters(py_phlex_module* mod, return false; } - pos += 18; - + std::string suffix = inp_type.substr(pos); std::string py_out = cname + "_" + inp + "py"; - if (inp_type.compare(pos, std::string::npos, "int32]]") == 0) { - mod->ph_module->transform("pyvint_" + inp + "_" + cname, vint_to_py, concurrency::serial) - .input_family(product_query{product_specification::create(inp), LAYER}) - .output_products(py_out); - } else if (inp_type.compare(pos, std::string::npos, "uint32]]") == 0) { + + if (suffix.find("uint32]]") != std::string::npos) { mod->ph_module->transform("pyvuint_" + inp + "_" + cname, vuint_to_py, concurrency::serial) .input_family(product_query{product_specification::create(inp), LAYER}) .output_products(py_out); - } else if (inp_type.compare(pos, std::string::npos, "int64]]") == 0) { // need not be true - mod->ph_module->transform("pyvlong_" + inp + "_" + cname, vlong_to_py, concurrency::serial) + } else if (suffix.find("int32]]") != std::string::npos) { + mod->ph_module->transform("pyvint_" + inp + "_" + cname, vint_to_py, concurrency::serial) .input_family(product_query{product_specification::create(inp), LAYER}) .output_products(py_out); - } else if (inp_type.compare(pos, std::string::npos, "uint64]]") == 0) { // id. + } else if (suffix.find("uint64]]") != std::string::npos) { // id. mod->ph_module ->transform("pyvulong_" + inp + "_" + cname, vulong_to_py, concurrency::serial) .input_family(product_query{product_specification::create(inp), LAYER}) .output_products(py_out); - } else if (inp_type.compare(pos, std::string::npos, "float32]]") == 0) { + } else if (suffix.find("int64]]") != std::string::npos) { // need not be true + mod->ph_module->transform("pyvlong_" + inp + "_" + cname, vlong_to_py, concurrency::serial) + .input_family(product_query{product_specification::create(inp), LAYER}) + .output_products(py_out); + } else if (suffix.find("float32]]") != std::string::npos) { mod->ph_module ->transform("pyvfloat_" + inp + "_" + cname, vfloat_to_py, concurrency::serial) .input_family(product_query{product_specification::create(inp), LAYER}) .output_products(py_out); - } else if (inp_type.compare(pos, std::string::npos, "double64]]") == 0) { + } else if (suffix.find("float64]]") != std::string::npos) { mod->ph_module ->transform("pyvdouble_" + inp + "_" + cname, vdouble_to_py, concurrency::serial) .input_family(product_query{product_specification::create(inp), LAYER}) @@ -595,7 +940,23 @@ static bool insert_input_converters(py_phlex_module* mod, } } #endif - else { + else if (inp_type == "list[int]") { + std::string py_out = cname + "_" + inp + "py"; + mod->ph_module->transform("pyvint_" + inp + "_" + cname, vint_to_py, concurrency::serial) + .input_family(product_query{product_specification::create(inp), LAYER}) + .output_products(py_out); + } else if (inp_type == "list[float]") { + std::string py_out = cname + "_" + inp + "py"; + mod->ph_module->transform("pyvfloat_" + inp + "_" + cname, vfloat_to_py, concurrency::serial) + .input_family(product_query{product_specification::create(inp), LAYER}) + .output_products(py_out); + } else if (inp_type == "list[double]" || inp_type == "list['double']") { + std::string py_out = cname + "_" + inp + "py"; + mod->ph_module + ->transform("pyvdouble_" + inp + "_" + cname, vdouble_to_py, concurrency::serial) + .input_family(product_query{product_specification::create(inp), LAYER}) + .output_products(py_out); + } else { PyErr_Format(PyExc_TypeError, "unsupported input type \"%s\"", inp_type.c_str()); return false; } @@ -710,7 +1071,7 @@ static PyObject* md_transform(py_phlex_module* mod, PyObject* args, PyObject* kw ->transform("pyvfloat_" + output + "_" + cname, py_to_vfloat, concurrency::serial) .input_family(product_query{product_specification::create(py_in), LAYER}) .output_products(output); - } else if (output_type.compare(pos, std::string::npos, "double64]]") == 0) { + } else if (output_type.compare(pos, std::string::npos, "float64]]") == 0) { mod->ph_module ->transform("pyvdouble_" + output + "_" + cname, py_to_vdouble, concurrency::serial) .input_family(product_query{product_specification::create(py_in), LAYER}) @@ -721,7 +1082,23 @@ static PyObject* md_transform(py_phlex_module* mod, PyObject* args, PyObject* kw } } #endif - else { + else if (output_type == "list[int]") { + auto py_in = "py" + output + "_" + cname; + mod->ph_module->transform("pyvint_" + output + "_" + cname, py_to_vint, concurrency::serial) + .input_family(product_query{product_specification::create(py_in), LAYER}) + .output_products(output); + } else if (output_type == "list[float]") { + auto py_in = "py" + output + "_" + cname; + mod->ph_module->transform("pyvfloat_" + output + "_" + cname, py_to_vfloat, concurrency::serial) + .input_family(product_query{product_specification::create(py_in), LAYER}) + .output_products(output); + } else if (output_type == "list[double]" || output_type == "list['double']") { + auto py_in = "py" + output + "_" + cname; + mod->ph_module + ->transform("pyvdouble_" + output + "_" + cname, py_to_vdouble, concurrency::serial) + .input_family(product_query{product_specification::create(py_in), LAYER}) + .output_products(output); + } else { PyErr_Format(PyExc_TypeError, "unsupported output type \"%s\"", output_type.c_str()); return nullptr; } diff --git a/scripts/README.md b/scripts/README.md index f50e3d23..61932feb 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -156,12 +156,27 @@ Provides convenient commands for managing code coverage analysis. ```bash # From repository root -./scripts/coverage.sh [COMMAND] [COMMAND...] +./scripts/coverage.sh [--preset ] [COMMAND] [COMMAND...] # Multiple commands in sequence ./scripts/coverage.sh setup test xml html ``` +#### Presets + +The `--preset` flag controls the toolchain and instrumentation method: + +- **`coverage-clang`** (Default): + - Uses LLVM source-based coverage. + - Best for local development (fast, accurate). + - Generates high-fidelity HTML reports. + - Key commands: `setup`, `test`, `html`, `view`, `summary`. + +- **`coverage-gcc`**: + - Uses `gcov` instrumentation. + - Best for CI pipelines requiring XML output (e.g., Codecov). + - Key commands: `setup`, `test`, `xml`, `upload`. + #### Commands | Command | Description | diff --git a/scripts/coverage.sh b/scripts/coverage.sh index 762d59bd..7818a165 100755 --- a/scripts/coverage.sh +++ b/scripts/coverage.sh @@ -104,47 +104,55 @@ usage() { # Initialize environment detection before showing paths detect_build_environment - echo "Usage: $0 [COMMAND] [COMMAND...]" + echo "Usage: $0 [--preset ] [COMMAND] [COMMAND...]" echo "" echo "Commands:" - echo " setup Set up coverage build directory" + echo " setup Set up coverage build directory (configure and build)" echo " clean Clean coverage data files" - echo " test Run tests with coverage" - echo " report Generate coverage reports" - echo " xml Generate XML coverage report" - echo " html Generate HTML coverage report" - echo " view Open HTML coverage report in browser" - echo " summary Show coverage summary" - echo " upload Upload coverage to Codecov" + echo " test Run tests with coverage instrumentation" + echo " report Generate primary coverage output (clang: text summary, gcc: gcov data bundle)" + echo " xml (gcc only) Generate Cobertura XML report" + echo " html Generate HTML coverage report (supported for both presets)" + echo " view Open HTML coverage report in browser (supported for both presets)" + echo " summary Show coverage summary in the terminal" + echo " upload Upload coverage report to Codecov (clang: lcov, gcc: xml)" echo " all Run setup, test, and generate all reports" echo " help Show this help message" echo "" - echo "Notes:" - echo " - The coverage-clang preset generates LLVM text summaries via 'report', 'summary', 'view', and 'all'." - echo " - Commands 'xml', 'html', and 'upload' require GCC instrumentation (coverage-gcc preset)." + echo "Toolchain Workflows (Presets):" + echo "" + echo " The --preset flag controls which compiler toolchain is used for coverage." + echo "" + echo " Clang (default): --preset coverage-clang" + echo " - Recommended for local development; mirrors the CI workflow." + echo " - Generates fast, accurate coverage data using LLVM's instrumentation." + echo " - Key commands: setup, test, html, view, summary" echo "" - echo "Important: Coverage data workflow" - echo " 1. After modifying source code, you MUST rebuild before generating reports:" + echo " GCC: --preset coverage-gcc" + echo " - Use to generate XML reports for services like Codecov." + echo " - Uses gcov instrumentation." + echo " - Key commands: setup, test, xml, html, upload" + echo "" + echo "Notes:" + echo " - Default preset is 'coverage-clang' to match the CI workflow." + echo " - After modifying source code, you MUST rebuild before generating reports:" echo " $0 setup test html # Rebuild → test → generate HTML" echo " $0 all # Complete workflow (recommended)" - echo " 2. Coverage data (.gcda/.gcno files) become stale when source files change." - echo " 3. Stale data causes 'source file is newer than notes file' errors." echo "" echo "Multiple commands can be specified and will be executed in sequence:" echo " $0 setup test summary" echo " $0 clean setup test html view" echo "" - echo "Codecov Token Setup (choose one method):" + echo "Codecov Token Setup (for 'upload' command):" echo " export CODECOV_TOKEN='your-token'" - echo " echo 'your-token' > ~/.codecov_token && chmod 600 ~/.codecov_token" echo "" echo "Environment variables:" echo " BUILD_DIR Override build directory (default: $BUILD_DIR)" echo "" echo "Examples:" - echo " $0 all # Complete workflow (recommended)" - echo " $0 setup test html # Manual workflow after code changes" - echo " $0 xml && $0 upload # Generate and upload" + echo " $0 all # Complete workflow using clang (default)" + echo " $0 --preset coverage-gcc all # Complete workflow using gcc" + echo " $0 setup test html view # Manual workflow after code changes" } check_build_dir() { @@ -203,7 +211,8 @@ ensure_coverage_configured() { if [[ "$build_type" != "Coverage" || "$coverage_enabled" != "ON" ]]; then warn "Coverage build cache not configured correctly (BUILD_TYPE=$build_type, ENABLE_COVERAGE=$coverage_enabled)" need_setup=1 - elif [[ "$COVERAGE_PRESET" != "coverage-clang" ]]; then + # GCC-specific staleness check for .gcno files + elif [[ "$COVERAGE_PRESET" == "coverage-gcc" ]]; then find_stale_instrumentation "$BUILD_DIR" "$PROJECT_SOURCE" local instrumentation_status=$? if [[ $instrumentation_status -eq 1 ]]; then @@ -234,6 +243,17 @@ run_tests_internal() { log "Running tests with coverage..." fi + # For Clang, set the LLVM_PROFILE_FILE env var to collect raw profile data + # in a centralized location, mirroring the CI workflow. + if [[ "$COVERAGE_PRESET" == "coverage-clang" ]]; then + local PROFILE_ROOT="$BUILD_DIR/test/profraw" + log "Cleaning LLVM profile directory: $PROFILE_ROOT" + rm -rf "$PROFILE_ROOT" + mkdir -p "$PROFILE_ROOT" + export LLVM_PROFILE_FILE="$PROFILE_ROOT/%m-%p.profraw" + log "LLVM_PROFILE_FILE set to: $LLVM_PROFILE_FILE" + fi + (cd "$BUILD_DIR" && ctest -j "$(nproc)" --output-on-failure) if [[ "$mode" == "auto" ]]; then @@ -250,8 +270,13 @@ ensure_tests_current() { return 0 fi + # For Clang, the workflow is much simpler than for GCC. We don't have the + # complex .gcno/.gcda staleness checks. We rely on ensure_coverage_configured + # to ensure the coverage build is present; run the `setup` command first if + # the coverage build has not been configured yet, then run the tests. if [[ "$COVERAGE_PRESET" == "coverage-clang" ]]; then ensure_coverage_configured + # If tests haven't been run in this session, run them to generate .profraw if [[ "${COVERAGE_TESTS_READY:-0}" != "1" ]]; then run_tests_internal "auto" fi @@ -259,17 +284,18 @@ ensure_tests_current() { return 0 fi + # --- GCC-specific logic below --- ensure_coverage_configured check_coverage_freshness local freshness_status=$? case "$freshness_status" in - 0) + 0) # Fresh COVERAGE_TESTS_READY=1 return 0 ;; - 1) + 1) # Missing .gcda files find_stale_instrumentation "$BUILD_DIR" "$PROJECT_SOURCE" local instrumentation_status=$? if [[ $instrumentation_status -eq 2 ]]; then @@ -279,7 +305,7 @@ ensure_tests_current() { fi run_tests_internal "auto" ;; - 2) + 2) # Stale .gcno files warn "Coverage instrumentation is stale; rebuilding before running tests..." setup_coverage COVERAGE_TESTS_READY=0 @@ -428,7 +454,7 @@ setup_coverage() { if [[ "$needs_reconfigure" == "true" ]]; then log "Configuring CMake for coverage build..." - local preset_name="${COVERAGE_PRESET:-coverage-gcc}" + local preset_name="${COVERAGE_PRESET:-coverage-clang}" log "Using CMake coverage preset: $preset_name" cmake --preset "$preset_name" \ -G Ninja \ @@ -578,23 +604,7 @@ generate_llvm_report() { exit 1 fi - detect_build_environment - - if [[ ! -d "$BUILD_DIR" ]] || [[ ! -f "$BUILD_DIR/CMakeCache.txt" ]]; then - setup_coverage - fi - - local profraw_found - profraw_found=$(find "$BUILD_DIR" -name "*.profraw" -type f -size +0c -print -quit 2>/dev/null || true) - if [[ -z "$profraw_found" ]]; then - warn "No LLVM profile data found; running tests to generate profiles..." - run_tests_internal "auto" - profraw_found=$(find "$BUILD_DIR" -name "*.profraw" -type f -size +0c -print -quit 2>/dev/null || true) - if [[ -z "$profraw_found" ]]; then - error "LLVM profile data is still missing after running tests" - exit 1 - fi - fi + ensure_tests_current log "Generating LLVM coverage summary..." if ! cmake --build "$BUILD_DIR" --target coverage-llvm; then @@ -625,6 +635,35 @@ generate_llvm_report() { fi } +generate_llvm_html_report() { + log "Generating LLVM HTML report..." + # Generate LLVM coverage summary and .info export (also logs summary) before HTML report + generate_llvm_report + + local lcov_path="$BUILD_DIR/coverage-llvm.info" + if [[ ! -f "$lcov_path" ]]; then + error "LLVM LCOV export not found at $lcov_path. Cannot generate HTML report." + exit 1 + fi + + if ! command -v genhtml >/dev/null 2>&1; then + error "'genhtml' command not found, which is required for HTML report generation." + error "Please install lcov: 'sudo apt-get install lcov' or 'brew install lcov'" + exit 1 + fi + + (cd "$BUILD_DIR" && genhtml -o coverage-html "$lcov_path" --title \ + "Phlex Coverage Report (Clang)" --show-details --legend --branch-coverage \ + --ignore-errors mismatch,inconsistent,negative,empty) + + if [[ -d "$BUILD_DIR/coverage-html" ]]; then + success "HTML coverage report generated: $BUILD_DIR/coverage-html/" + else + error "Failed to generate HTML report from LLVM data." + exit 1 + fi +} + show_summary() { ensure_tests_current check_build_dir @@ -632,31 +671,47 @@ show_summary() { cmake --build "$BUILD_DIR" --target coverage-summary } -view_html() { - ensure_tests_current - check_build_dir - - if [[ ! -d "$BUILD_DIR/coverage-html" ]]; then - log "HTML coverage report not found. Generating it now..." - generate_html - fi - +view_html_internal() { log "Opening HTML coverage report..." if command -v xdg-open >/dev/null 2>&1; then xdg-open "$BUILD_DIR/coverage-html/index.html" elif command -v open >/dev/null 2>&1; then open "$BUILD_DIR/coverage-html/index.html" else - echo "HTML report available at: $BUILD_DIR/coverage-html/index.html" + local report_path="$BUILD_DIR/coverage-html/index.html" + local file_url="" + if command -v python3 >/dev/null 2>&1; then + file_url="$(python3 -c "import pathlib, sys; print(pathlib.Path(sys.argv[1]).resolve().as_uri())" "$report_path")" + else + file_url="file://$report_path" + fi + echo "HTML report available at: $file_url" fi } upload_codecov() { check_build_dir - if [[ ! -f "$BUILD_DIR/coverage.xml" ]]; then - warn "XML coverage report not found. Generate it first with '$0 xml'" - exit 1 + local coverage_file="" + if [[ "$COVERAGE_PRESET" == "coverage-clang" ]]; then + coverage_file="coverage-llvm.info" + if [[ ! -f "$BUILD_DIR/$coverage_file" ]]; then + warn "Clang LCOV report not found. Generate it first with '$0 report'" + exit 1 + fi + else + coverage_file="coverage.xml" + if [[ ! -f "$BUILD_DIR/$coverage_file" ]]; then + warn "GCC XML report not found. Generate it first with '$0 xml'" + exit 1 + fi + log "Ensuring coverage XML paths are normalized before upload..." + if ! cmake --build "$BUILD_DIR" --target coverage-xml-normalize; then + error "Coverage XML failed normalization. Investigate filters/excludes before uploading." + exit 1 + fi + log "Coverage XML source roots after normalization:" + grep -o '.*' "$BUILD_DIR/coverage.xml" | head -5 | sed 's/^/ /' fi # Check for codecov CLI @@ -709,11 +764,11 @@ upload_codecov() { log "Uploading coverage to Codecov..." log "Git root: $GIT_ROOT" log "Commit SHA: $COMMIT_SHA" - log "Coverage file: $BUILD_DIR/coverage.xml" + log "Coverage file: $BUILD_DIR/$coverage_file" # Build codecov command CODECOV_CMD=(codecov upload-coverage - --file coverage.xml + --file "$coverage_file" --commit-sha "$COMMIT_SHA" --working-dir "$GIT_ROOT") @@ -735,8 +790,10 @@ run_all() { run_tests if [[ "$COVERAGE_PRESET" == "coverage-clang" ]]; then generate_llvm_report + generate_llvm_html_report success "Complete LLVM coverage analysis finished!" log "Summary report: $BUILD_DIR/coverage-llvm.txt" + log "HTML report: $BUILD_DIR/coverage-html/index.html" else check_build_dir log "Generating GCC coverage report bundle..." @@ -755,12 +812,7 @@ run_all() { } -# Select coverage preset: coverage-gcc (default) or coverage-clang -COVERAGE_PRESET="${COVERAGE_PRESET:-coverage-gcc}" -if [[ "$1" == "--preset" && -n "$2" ]]; then - COVERAGE_PRESET="$2" - shift 2 -fi +# Main script execution starts here # Execute a single command execute_command() { @@ -792,7 +844,7 @@ execute_command() { ;; xml) if [[ "$COVERAGE_PRESET" == "coverage-clang" ]]; then - error "XML report generation is not supported with the coverage-clang preset. Use 'report' or switch to coverage-gcc." + error "XML report generation is not supported with the coverage-clang preset. Use the 'coverage-gcc' preset for XML/Codecov reports." exit 1 else generate_xml @@ -800,18 +852,22 @@ execute_command() { ;; html) if [[ "$COVERAGE_PRESET" == "coverage-clang" ]]; then - error "HTML report generation is not supported with the coverage-clang preset. Use 'report' or switch to coverage-gcc." - exit 1 + generate_llvm_html_report else generate_html fi ;; view) - if [[ "$COVERAGE_PRESET" == "coverage-clang" ]]; then - generate_llvm_report - else - view_html + check_build_dir + if [[ ! -d "$BUILD_DIR/coverage-html" ]]; then + log "HTML coverage report not found. Generating it now..." + if [[ "$COVERAGE_PRESET" == "coverage-clang" ]]; then + generate_llvm_html_report + else + generate_html + fi fi + view_html_internal ;; summary) if [[ "$COVERAGE_PRESET" == "coverage-clang" ]]; then @@ -821,10 +877,6 @@ execute_command() { fi ;; upload) - if [[ "$COVERAGE_PRESET" == "coverage-clang" ]]; then - error "Codecov upload currently supports GCC/gcov outputs. Switch to coverage-gcc to upload XML coverage." - exit 1 - fi upload_codecov ;; all) @@ -848,27 +900,49 @@ if [ $# -eq 0 ]; then exit 0 fi -# Parse options +# Default preset, can be overridden by --preset +COVERAGE_PRESET="coverage-clang" +COMMANDS=() + +# Parse arguments while [[ $# -gt 0 ]]; do case "$1" in + --preset) + if [[ -z "$2" || "$2" == -* ]]; then + error "Missing value for --preset option" + exit 1 + fi + if [[ "$2" != "coverage-clang" && "$2" != "coverage-gcc" ]]; then + error "Invalid value for --preset: '$2'. Must be 'coverage-clang' or 'coverage-gcc'." + exit 1 + fi + COVERAGE_PRESET="$2" + shift 2 + ;; --help|-h|help) usage exit 0 ;; -*) error "Unknown option: $1" - echo "" usage exit 1 ;; *) - # Not an option, must be a command - break + # Collect commands + COMMANDS+=("$1") + shift ;; esac done +# If no commands were provided, show usage and indicate error +if [ ${#COMMANDS[@]} -eq 0 ]; then + usage + exit 1 +fi + # Process all commands in sequence -for cmd in "$@"; do +for cmd in "${COMMANDS[@]}"; do execute_command "$cmd" done diff --git a/scripts/sarif-alerts.py b/scripts/sarif-alerts.py index f421598c..37173c1e 100644 --- a/scripts/sarif-alerts.py +++ b/scripts/sarif-alerts.py @@ -1,4 +1,5 @@ """A simple tool to print SARIF results from one or more SARIF files.""" + import argparse import json from pathlib import Path diff --git a/test/demo-giantdata/unfold_transform_fold.cpp b/test/demo-giantdata/unfold_transform_fold.cpp index c660c844..7873b2e6 100644 --- a/test/demo-giantdata/unfold_transform_fold.cpp +++ b/test/demo-giantdata/unfold_transform_fold.cpp @@ -36,7 +36,7 @@ TEST_CASE("Unfold-transform-fold pipeline", "[concurrency][unfold][fold]") // Test parameters - moderate scale to ensure sustained concurrent execution constexpr std::size_t n_runs = 1; constexpr std::size_t n_subruns = 1; - constexpr std::size_t n_spills = 20; + constexpr std::size_t n_spills = 100; constexpr int apas_per_spill = 20; constexpr std::size_t wires_per_spill = apas_per_spill * 256ull; constexpr std::size_t chunksize = 256; diff --git a/test/python/CMakeLists.txt b/test/python/CMakeLists.txt index 113b7059..d49baf7d 100644 --- a/test/python/CMakeLists.txt +++ b/test/python/CMakeLists.txt @@ -14,13 +14,20 @@ if(Python_FOUND) ${Python_EXECUTABLE} -c "import sys try: import ${MODULE_NAME} - from packaging.version import parse as parse_version installed_version = getattr(${MODULE_NAME}, '__version__', None) - if parse_version(installed_version) >= parse_version('${MIN_VERSION}'): + if not installed_version: + sys.exit(2) + + def parse(v): + return tuple(map(int, v.split('.')[:3])) + + if parse(installed_version) >= parse('${MIN_VERSION}'): sys.exit(0) else: sys.exit(2) # Version too low except ImportError: + sys.exit(1) +except Exception: sys.exit(1)" RESULT_VARIABLE _module_check_result ) @@ -100,6 +107,21 @@ except ImportError: ${CMAKE_CURRENT_SOURCE_DIR}/pyvec.jsonnet ) list(APPEND ACTIVE_PY_CPHLEX_TESTS py:vec) + + add_test(NAME py:vectypes + COMMAND phlex -c ${CMAKE_CURRENT_SOURCE_DIR}/pyvectypes.jsonnet + ) + list(APPEND ACTIVE_PY_CPHLEX_TESTS py:vectypes) + + add_test(NAME py:veclists + COMMAND phlex -c ${CMAKE_CURRENT_SOURCE_DIR}/pyveclists.jsonnet + ) + list(APPEND ACTIVE_PY_CPHLEX_TESTS py:veclists) + + add_test(NAME py:types COMMAND phlex -c + ${CMAKE_CURRENT_SOURCE_DIR}/pytypes.jsonnet + ) + list(APPEND ACTIVE_PY_CPHLEX_TESTS py:types) endif() # phlex-based tests (no cppyy dependency) @@ -118,25 +140,51 @@ except ImportError: ) list(APPEND ACTIVE_PY_CPHLEX_TESTS py:reduce) - # "failing" tests for checking error paths - add_test( - NAME py:failure - COMMAND - ${CMAKE_CURRENT_SOURCE_DIR}/failing_test_wrap.sh - ${PROJECT_BINARY_DIR}/bin/phlex -c - ${CMAKE_CURRENT_SOURCE_DIR}/pyfailure.jsonnet + add_test(NAME py:coverage + COMMAND phlex -c ${CMAKE_CURRENT_SOURCE_DIR}/pycoverage.jsonnet + ) + list(APPEND ACTIVE_PY_CPHLEX_TESTS py:coverage) + + add_test(NAME py:mismatch + COMMAND ${PROJECT_BINARY_DIR}/bin/phlex -c + ${CMAKE_CURRENT_SOURCE_DIR}/pymismatch.jsonnet + ) + set_tests_properties( + py:mismatch + PROPERTIES PASS_REGULAR_EXPRESSION + "number of inputs .* does not match number of annotation types" ) + list(APPEND ACTIVE_PY_CPHLEX_TESTS py:mismatch) + + # "failing" tests for checking error paths + add_test(NAME py:failure + COMMAND ${PROJECT_BINARY_DIR}/bin/phlex -c + ${CMAKE_CURRENT_SOURCE_DIR}/pyfailure.jsonnet + ) set_tests_properties( py:failure PROPERTIES PASS_REGULAR_EXPRESSION "property \"input\" does not exist" ) list(APPEND ACTIVE_PY_CPHLEX_TESTS py:failure) + message(STATUS "Python_SITELIB: ${Python_SITELIB}") + message(STATUS "Python_SITEARCH: ${Python_SITEARCH}") set(TEST_PYTHONPATH ${CMAKE_CURRENT_SOURCE_DIR}) + # Always add site-packages to PYTHONPATH for tests, as embedded python might + # not find them especially in spack environments where they are in + # non-standard locations + if(Python_SITELIB) + set(TEST_PYTHONPATH ${TEST_PYTHONPATH}:${Python_SITELIB}) + endif() + if(Python_SITEARCH AND NOT "${Python_SITEARCH}" STREQUAL "${Python_SITELIB}") + set(TEST_PYTHONPATH ${TEST_PYTHONPATH}:${Python_SITEARCH}) + endif() + if(DEFINED ENV{VIRTUAL_ENV}) - set(TEST_PYTHONPATH ${TEST_PYTHONPATH}:${Python_SITELIB}:${Python_SITEARCH}) + # Keep this for backward compatibility or if it adds something else endif() set(TEST_PYTHONPATH ${TEST_PYTHONPATH}:$ENV{PYTHONPATH}) + message(STATUS "TEST_PYTHONPATH: ${TEST_PYTHONPATH}") set_tests_properties( ${ACTIVE_PY_CPHLEX_TESTS} diff --git a/test/python/adder.py b/test/python/adder.py index 1aab1956..56bb88a6 100644 --- a/test/python/adder.py +++ b/test/python/adder.py @@ -4,6 +4,7 @@ real. It serves as a "Hello, World" equivalent for running Python code. """ + def add(i: int, j: int) -> int: """Add the inputs together and return the sum total. @@ -39,7 +40,4 @@ def PHLEX_EXPERIMENTAL_REGISTER_ALGORITHMS(m, config): Returns: None """ - m.transform(add, - input_family = config["input"], - output_products = config["output"]) - + m.transform(add, input_family=config["input"], output_products=config["output"]) diff --git a/test/python/all_config.py b/test/python/all_config.py index 59cbfa61..48683247 100644 --- a/test/python/all_config.py +++ b/test/python/all_config.py @@ -5,6 +5,7 @@ to Python. The actual run is a noop. """ + class ConfigConsumer: """A callable class that "needs" every configuration type. @@ -12,7 +13,7 @@ class ConfigConsumer: __name__ (str): Identifier for Phlex. """ - __name__ = 'config_consumer' + __name__ = "config_consumer" def __init__(self, config): """Create a config consumer object. @@ -28,18 +29,18 @@ def __init__(self, config): None """ # builtin types - assert config['a_bool'] == False # noqa: E712 # we really want to check False - assert config['an_int'] == -37 - assert config['a_uint'] == 18446744073709551616 - assert config['a_float'] == 3.1415 - assert config['a_string'] == 'foo' + assert config["a_bool"] == False # noqa: E712 # we really want to check False + assert config["an_int"] == -37 + assert config["a_uint"] == 18446744073709551616 + assert config["a_float"] == 3.1415 + assert config["a_string"] == "foo" # collection types - assert config['some_bools'] == (False, True) - assert config['some_ints'] == (-1, 42, -55) - assert config['some_uints'] == (18446744073709551616, 29, 137) - assert config['some_floats'] == (3.1415, 2.71828) - assert config['some_strings'] == ('aap', 'noot', 'mies') + assert config["some_bools"] == (False, True) + assert config["some_ints"] == (-1, 42, -55) + assert config["some_uints"] == (18446744073709551616, 29, 137) + assert config["some_floats"] == (3.1415, 2.71828) + assert config["some_strings"] == ("aap", "noot", "mies") def __call__(self, i: int, j: int) -> None: """Dummy routine to do something. @@ -71,5 +72,4 @@ def PHLEX_EXPERIMENTAL_REGISTER_ALGORITHMS(m, config): None """ config_consumer = ConfigConsumer(config) - m.observe(config_consumer, input_family = config["input"]) - + m.observe(config_consumer, input_family=config["input"]) diff --git a/test/python/pycoverage.jsonnet b/test/python/pycoverage.jsonnet new file mode 100644 index 00000000..bd67b970 --- /dev/null +++ b/test/python/pycoverage.jsonnet @@ -0,0 +1,18 @@ +{ + driver: { + cpp: 'generate_layers', + layers: { + event: { parent: 'job', total: 1, starting_number: 1 } + } + }, + sources: { + cppdriver: { + cpp: 'cppsource4py', + }, + }, + modules: { + coverage: { + py: 'test_coverage', + } + } +} diff --git a/test/python/pymismatch.jsonnet b/test/python/pymismatch.jsonnet new file mode 100644 index 00000000..a3b1abbf --- /dev/null +++ b/test/python/pymismatch.jsonnet @@ -0,0 +1,13 @@ +{ + driver: { + cpp: 'generate_layers', + layers: { + event: { total: 1 } + } + }, + modules: { + mismatch: { + py: 'test_mismatch', + } + } +} diff --git a/test/python/pytypes.jsonnet b/test/python/pytypes.jsonnet new file mode 100644 index 00000000..27fd6a0e --- /dev/null +++ b/test/python/pytypes.jsonnet @@ -0,0 +1,33 @@ +{ + driver: { + cpp: 'generate_layers', + layers: { + event: { parent: 'job', total: 10, starting_number: 1 } + } + }, + sources: { + cppdriver: { + cpp: 'cppsource4py', + }, + }, + modules: { + pytypes: { + py: 'test_types', + input_float: ['f1', 'f2'], + output_float: ['sum_f'], + input_double: ['d1', 'd2'], + output_double: ['sum_d'], + input_uint: ['u1', 'u2'], + output_uint: ['sum_u'], + input_bool: ['b1', 'b2'], + output_bool: ['and_b'], + output_vfloat: ['vec_f'], + output_vdouble: ['vec_d'], + }, + verify_bool: { + py: 'verify_extended', + input_bool: ['and_b'], + expected_bool: false, + }, + }, +} diff --git a/test/python/pyveclists.jsonnet b/test/python/pyveclists.jsonnet new file mode 100644 index 00000000..dfcbc3ff --- /dev/null +++ b/test/python/pyveclists.jsonnet @@ -0,0 +1,61 @@ +{ + driver: { + cpp: 'generate_layers', + layers: { + event: { parent: "job", total: 10, starting_number: 1 } + } + }, + sources: { + cppdriver: { + cpp: 'cppsource4py', + }, + }, + modules: { + vectypes: { + py: 'vectypes', + use_lists: true, + input_int32: ['i', 'j'], + output_int32: ['sum_int32'], + input_uint32: ['u1', 'u2'], + output_uint32: ['sum_uint32'], + input_int64: ['l1', 'l2'], + output_int64: ['sum_int64'], + input_uint64: ['ul1', 'ul2'], + output_uint64: ['sum_uint64'], + input_float32: ['f1', 'f2'], + output_float32: ['sum_float32'], + input_float64: ['d1', 'd2'], + output_float64: ['sum_float64'], + }, + verify_int32: { + py: 'verify_extended', + input_int: ['sum_int32'], + sum_total: 1, + }, + verify_uint32: { + py: 'verify_extended', + input_uint: ['sum_uint32'], + sum_total: 1, + }, + verify_int64: { + py: 'verify_extended', + input_long: ['sum_int64'], + sum_total: 1, + }, + verify_uint64: { + py: 'verify_extended', + input_ulong: ['sum_uint64'], + sum_total: 100, + }, + verify_float32: { + py: 'verify_extended', + input_float: ['sum_float32'], + sum_total: 1.0, + }, + verify_double: { + py: 'verify_extended', + input_double: ['sum_float64'], + sum_total: 1.0, + }, + }, +} diff --git a/test/python/pyvectypes.jsonnet b/test/python/pyvectypes.jsonnet new file mode 100644 index 00000000..a655687b --- /dev/null +++ b/test/python/pyvectypes.jsonnet @@ -0,0 +1,60 @@ +{ + driver: { + cpp: 'generate_layers', + layers: { + event: { parent: "job", total: 10, starting_number: 1 } + } + }, + sources: { + cppdriver: { + cpp: 'cppsource4py', + }, + }, + modules: { + vectypes: { + py: 'vectypes', + input_int32: ['i', 'j'], + output_int32: ['sum_int32'], + input_uint32: ['u1', 'u2'], + output_uint32: ['sum_uint32'], + input_int64: ['l1', 'l2'], + output_int64: ['sum_int64'], + input_uint64: ['ul1', 'ul2'], + output_uint64: ['sum_uint64'], + input_float32: ['f1', 'f2'], + output_float32: ['sum_float32'], + input_float64: ['d1', 'd2'], + output_float64: ['sum_float64'], + }, + verify_int32: { + py: 'verify_extended', + input_int: ['sum_int32'], + sum_total: 1, + }, + verify_uint32: { + py: 'verify_extended', + input_uint: ['sum_uint32'], + sum_total: 1, + }, + verify_int64: { + py: 'verify_extended', + input_long: ['sum_int64'], + sum_total: 1, + }, + verify_uint64: { + py: 'verify_extended', + input_ulong: ['sum_uint64'], + sum_total: 100, + }, + verify_float32: { + py: 'verify_extended', + input_float: ['sum_float32'], + sum_total: 1.0, + }, + verify_double: { + py: 'verify_extended', + input_double: ['sum_float64'], + sum_total: 1.0, + }, + }, +} diff --git a/test/python/reducer.py b/test/python/reducer.py index 90501c28..9aa921d2 100644 --- a/test/python/reducer.py +++ b/test/python/reducer.py @@ -11,6 +11,7 @@ lifetime issues (if they are not). """ + def add(i: int, j: int) -> int: """Add the inputs together and return the sum total. @@ -49,24 +50,13 @@ def PHLEX_EXPERIMENTAL_REGISTER_ALGORITHMS(m, config): """ # first recieve the same input x4 but return "different" output for i in range(4): - m.transform(add, - name = "reduce%d" % i, - input_family = config["input"], - output_products = ["sum%d" % i]) + m.transform( + add, name="reduce%d" % i, input_family=config["input"], output_products=["sum%d" % i] + ) # now reduce them pair-wise - m.transform(add, - name = "reduce01", - input_family = ["sum0", "sum1"], - output_products = ["sum01"]) - m.transform(add, - name = "reduce23", - input_family = ["sum2", "sum3"], - output_products = ["sum23"]) + m.transform(add, name="reduce01", input_family=["sum0", "sum1"], output_products=["sum01"]) + m.transform(add, name="reduce23", input_family=["sum2", "sum3"], output_products=["sum23"]) # once more (and the configuration will add a verifier) - m.transform(add, - name = "reduce", - input_family = ["sum01", "sum23"], - output_products = ["sum"]) - + m.transform(add, name="reduce", input_family=["sum01", "sum23"], output_products=["sum"]) diff --git a/test/python/source.cpp b/test/python/source.cpp index fa33f10c..81035c05 100644 --- a/test/python/source.cpp +++ b/test/python/source.cpp @@ -1,12 +1,61 @@ #include "phlex/source.hpp" #include "phlex/model/data_cell_index.hpp" +#include using namespace phlex::experimental; PHLEX_EXPERIMENTAL_REGISTER_PROVIDERS(s) { - s.provide("provide_i", [](data_cell_index const& id) -> int { return id.number(); }) + s.provide("provide_i", [](data_cell_index const& id) -> int { return id.number() % 2; }) .output_product("i"_in("job")); - s.provide("provide_j", [](data_cell_index const& id) -> int { return -id.number() + 1; }) + s.provide("provide_j", + [](data_cell_index const& id) -> int { return 1 - (int)(id.number() % 2); }) .output_product("j"_in("job")); + + s.provide("provide_f1", + [](data_cell_index const& id) -> float { return (float)((id.number() % 100) / 100.0); }) + .output_product("f1"_in("job")); + s.provide( + "provide_f2", + [](data_cell_index const& id) -> float { return 1.0f - (float)((id.number() % 100) / 100.0); }) + .output_product("f2"_in("job")); + + s.provide( + "provide_d1", + [](data_cell_index const& id) -> double { return (double)((id.number() % 100) / 100.0); }) + .output_product("d1"_in("job")); + s.provide("provide_d2", + [](data_cell_index const& id) -> double { + return 1.0 - (double)((id.number() % 100) / 100.0); + }) + .output_product("d2"_in("job")); + + s.provide( + "provide_u1", + [](data_cell_index const& id) -> unsigned int { return (unsigned int)(id.number() % 2); }) + .output_product("u1"_in("job")); + s.provide( + "provide_u2", + [](data_cell_index const& id) -> unsigned int { return 1 - (unsigned int)(id.number() % 2); }) + .output_product("u2"_in("job")); + + s.provide("provide_l1", + [](data_cell_index const& id) -> int64_t { return (int64_t)(id.number() % 2); }) + .output_product("l1"_in("job")); + s.provide("provide_l2", + [](data_cell_index const& id) -> int64_t { return 1 - (int64_t)(id.number() % 2); }) + .output_product("l2"_in("job")); + + s.provide("provide_ul1", + [](data_cell_index const& id) -> uint64_t { return (uint64_t)(id.number() % 101); }) + .output_product("ul1"_in("job")); + s.provide( + "provide_ul2", + [](data_cell_index const& id) -> uint64_t { return 100 - (uint64_t)(id.number() % 101); }) + .output_product("ul2"_in("job")); + + s.provide("provide_b1", [](data_cell_index const& id) -> bool { return (id.number() % 2) == 0; }) + .output_product("b1"_in("job")); + s.provide("provide_b2", [](data_cell_index const& id) -> bool { return (id.number() % 2) != 0; }) + .output_product("b2"_in("job")); } diff --git a/test/python/sumit.py b/test/python/sumit.py index 1ea28faf..6b3052e5 100644 --- a/test/python/sumit.py +++ b/test/python/sumit.py @@ -27,6 +27,7 @@ def collectify(i: int, j: int) -> npt.NDArray[np.int32]: """ return np.array([i, j], dtype=np.int32) + def sum_array(coll: npt.NDArray[np.int32]) -> int: """Add the elements of the input collection and return the sum total. @@ -61,10 +62,5 @@ def PHLEX_EXPERIMENTAL_REGISTER_ALGORITHMS(m, config): Returns: None """ - m.transform(collectify, - input_family = config["input"], - output_products = ["my_pyarray"]) - m.transform(sum_array, - input_family = ["my_pyarray"], - output_products = config["output"]) - + m.transform(collectify, input_family=config["input"], output_products=["my_pyarray"]) + m.transform(sum_array, input_family=["my_pyarray"], output_products=config["output"]) diff --git a/test/python/test_coverage.py b/test/python/test_coverage.py new file mode 100644 index 00000000..df30b607 --- /dev/null +++ b/test/python/test_coverage.py @@ -0,0 +1,43 @@ +"""Test coverage for list input converters.""" + +class double(float): # noqa: N801 + """Dummy class for C++ double type.""" + + pass + +def list_int_func(lst: list[int]) -> int: + """Sum a list of integers.""" + return sum(lst) + +def list_float_func(lst: list[float]) -> float: + """Sum a list of floats.""" + return sum(lst) + +# For double, I'll use string annotation to be safe and match C++ check +def list_double_func(lst: "list[double]") -> float: # type: ignore + """Sum a list of doubles.""" + return sum(lst) + +def collect_int(i: int) -> list[int]: + """Collect an integer into a list.""" + return [i] + +def collect_float(f: float) -> list[float]: + """Collect a float into a list.""" + return [f] + +def collect_double(d: "double") -> "list[double]": # type: ignore + """Collect a double into a list.""" + return [d] + +def PHLEX_EXPERIMENTAL_REGISTER_ALGORITHMS(m, config): + """Register algorithms.""" + # We need to transform scalar inputs to lists first + # i, f1, d1 come from cppsource4py + m.transform(collect_int, input_family=["i"], output_products=["l_int"]) + m.transform(collect_float, input_family=["f1"], output_products=["l_float"]) + m.transform(collect_double, input_family=["d1"], output_products=["l_double"]) + + m.transform(list_int_func, input_family=["l_int"], output_products=["sum_int"]) + m.transform(list_float_func, input_family=["l_float"], output_products=["sum_float"]) + m.transform(list_double_func, input_family=["l_double"], output_products=["sum_double"]) diff --git a/test/python/test_mismatch.py b/test/python/test_mismatch.py new file mode 100644 index 00000000..ab7313d9 --- /dev/null +++ b/test/python/test_mismatch.py @@ -0,0 +1,12 @@ + +"""Test mismatch between input labels and types.""" + +def mismatch_func(a: int, b: int): + """Add two integers.""" + return a + b + +def PHLEX_EXPERIMENTAL_REGISTER_ALGORITHMS(m, config): + """Register algorithms.""" + # input_family has 1 element, but function takes 2 arguments + # This should trigger the error in modulewrap.cpp + m.transform(mismatch_func, input_family=["a"], output_products=["sum"]) diff --git a/test/python/test_types.py b/test/python/test_types.py new file mode 100644 index 00000000..3c060b92 --- /dev/null +++ b/test/python/test_types.py @@ -0,0 +1,129 @@ +"""Algorithms exercising various C++ types. + +This test code implements algorithms that use types other than the standard +int/string to ensure that the Python bindings correctly handle them. +""" + +import numpy as np +import numpy.typing as npt + + +class double(float): # noqa: N801 + """Dummy class for C++ double type.""" + + pass + + +def add_float(i: float, j: float) -> float: + """Add two floats. + + Args: + i (float): First input. + j (float): Second input. + + Returns: + float: Sum of the two inputs. + """ + return i + j + + +def add_double(i: double, j: double) -> double: + """Add two doubles. + + Args: + i (float): First input. + j (float): Second input. + + Returns: + float: Sum of the two inputs. + """ + return double(i + j) + + +def add_unsigned(i: "unsigned int", j: "unsigned int") -> "unsigned int": # type: ignore # noqa: F722 + """Add two unsigned integers. + + Args: + i (int): First input. + j (int): Second input. + + Returns: + int: Sum of the two inputs. + """ + return i + j + + +def collect_float(i: float, j: float) -> npt.NDArray[np.float32]: + """Combine floats into a numpy array. + + Args: + i (float): First input. + j (float): Second input. + + Returns: + ndarray: Array of floats. + """ + return np.array([i, j], dtype=np.float32) + + +def collect_double(i: double, j: double) -> npt.NDArray[np.float64]: + """Combine doubles into a numpy array. + + Args: + i (float): First input. + j (float): Second input. + + Returns: + ndarray: Array of doubles. + """ + return np.array([i, j], dtype=np.float64) + + +def and_bool(i: bool, j: bool) -> bool: + """And two booleans. + + Args: + i (bool): First input. + j (bool): Second input. + + Returns: + bool: Logical AND of the two inputs. + """ + return i and j + + +def PHLEX_EXPERIMENTAL_REGISTER_ALGORITHMS(m, config): + """Register algorithms. + + Args: + m (internal): Phlex registrar representation. + config (internal): Phlex configuration representation. + + Returns: + None + """ + m.transform( + add_float, input_family=config["input_float"], output_products=config["output_float"] + ) + + m.transform( + add_double, input_family=config["input_double"], output_products=config["output_double"] + ) + + m.transform( + add_unsigned, input_family=config["input_uint"], output_products=config["output_uint"] + ) + + m.transform( + and_bool, input_family=config["input_bool"], output_products=config["output_bool"] + ) + + m.transform( + collect_float, input_family=config["input_float"], output_products=config["output_vfloat"] + ) + + m.transform( + collect_double, + input_family=config["input_double"], + output_products=config["output_vdouble"], + ) diff --git a/test/python/vectypes.py b/test/python/vectypes.py new file mode 100644 index 00000000..4d613f74 --- /dev/null +++ b/test/python/vectypes.py @@ -0,0 +1,228 @@ +"""Algorithms exercising various numpy array types. + +This test code implements algorithms that use numpy arrays of different types +to ensure that the Python bindings correctly handle them. +""" + +import numpy as np +import numpy.typing as npt + + +def collectify_int32(i: int, j: int) -> npt.NDArray[np.int32]: + """Create an int32 array from two integers.""" + return np.array([i, j], dtype=np.int32) + + +def sum_array_int32(coll: npt.NDArray[np.int32]) -> int: + """Sum an int32 array.""" + if isinstance(coll, list): + coll = np.array(coll, dtype=np.int32) + return int(sum(int(x) for x in coll)) + + +def collectify_uint32( + i: "unsigned int", j: "unsigned int" # type: ignore # noqa: F722 +) -> npt.NDArray[np.uint32]: + """Create a uint32 array from two integers.""" + return np.array([i, j], dtype=np.uint32) + + +def sum_array_uint32(coll: npt.NDArray[np.uint32]) -> "unsigned int": # type: ignore # noqa: F722 + """Sum a uint32 array.""" + if isinstance(coll, list): + coll = np.array(coll, dtype=np.uint32) + return int(sum(int(x) for x in coll)) + + +def collectify_int64(i: "long", j: "long") -> npt.NDArray[np.int64]: # type: ignore # noqa: F821 + """Create an int64 array from two integers.""" + return np.array([i, j], dtype=np.int64) + + +def sum_array_int64(coll: npt.NDArray[np.int64]) -> "long": # type: ignore # noqa: F821 + """Sum an int64 array.""" + if isinstance(coll, list): + coll = np.array(coll, dtype=np.int64) + return int(sum(int(x) for x in coll)) + + +def collectify_uint64( + i: "unsigned long", j: "unsigned long" # type: ignore # noqa: F722 +) -> npt.NDArray[np.uint64]: + """Create a uint64 array from two integers.""" + return np.array([i, j], dtype=np.uint64) + + +def sum_array_uint64(coll: npt.NDArray[np.uint64]) -> "unsigned long": # type: ignore # noqa: F722 + """Sum a uint64 array.""" + if isinstance(coll, list): + coll = np.array(coll, dtype=np.uint64) + return int(sum(int(x) for x in coll)) + + +def collectify_float32(i: "float", j: "float") -> npt.NDArray[np.float32]: + """Create a float32 array from two floats.""" + return np.array([i, j], dtype=np.float32) + + +def sum_array_float32(coll: npt.NDArray[np.float32]) -> "float": + """Sum a float32 array.""" + return float(sum(coll)) + + +def collectify_float64(i: "double", j: "double") -> npt.NDArray[np.float64]: # type: ignore # noqa: F821 + """Create a float64 array from two floats.""" + return np.array([i, j], dtype=np.float64) + + +def collectify_float32_list(i: "float", j: "float") -> list[float]: + """Create a float32 list from two floats.""" + return [i, j] + + +def collectify_float64_list(i: "double", j: "double") -> list["double"]: # type: ignore # noqa: F821 + """Create a float64 list from two floats.""" + return [i, j] + + +def sum_array_float64(coll: npt.NDArray[np.float64]) -> "double": # type: ignore # noqa: F821 + """Sum a float64 array.""" + return float(sum(coll)) + + +def collectify_int32_list(i: int, j: int) -> list[int]: + """Create an int32 list from two integers.""" + return [i, j] + + +def collectify_uint32_list( + i: "unsigned int", j: "unsigned int" # type: ignore # noqa: F722 +) -> list[int]: + """Create a uint32 list from two integers.""" + return [int(i), int(j)] + + +def collectify_int64_list(i: "long", j: "long") -> list[int]: # type: ignore # noqa: F821 + """Create an int64 list from two integers.""" + return [int(i), int(j)] + + +def collectify_uint64_list( + i: "unsigned long", j: "unsigned long" # type: ignore # noqa: F722 +) -> list[int]: + """Create a uint64 list from two integers.""" + return [int(i), int(j)] + + +def sum_list_int32(coll: list[int]) -> int: + """Sum a list of ints.""" + return sum(coll) + + +def sum_list_uint32(coll: list[int]) -> "unsigned int": # type: ignore # noqa: F722 + """Sum a list of uints.""" + return sum(coll) + + +def sum_list_int64(coll: list[int]) -> "long": # type: ignore # noqa: F821 + """Sum a list of longs.""" + return sum(coll) + + +def sum_list_uint64(coll: list[int]) -> "unsigned long": # type: ignore # noqa: F722 + """Sum a list of ulongs.""" + return sum(coll) + + +def sum_list_float(coll: list[float]) -> float: + """Sum a list of floats.""" + return sum(coll) + + +def sum_list_double(coll: list["double"]) -> "double": # type: ignore # noqa: F821 + """Sum a list of doubles.""" + return float(sum(coll)) + + +def PHLEX_EXPERIMENTAL_REGISTER_ALGORITHMS(m, config): + """Register algorithms for the test.""" + try: + use_lists = config["use_lists"] + except (KeyError, TypeError): + use_lists = False + + # int32 + m.transform( + collectify_int32_list if use_lists else collectify_int32, + input_family=config["input_int32"], + output_products=["arr_int32"], + ) + m.transform( + sum_list_int32 if use_lists else sum_array_int32, + input_family=["arr_int32"], + output_products=config["output_int32"], + name="sum_int32", + ) + + # uint32 + m.transform( + collectify_uint32_list if use_lists else collectify_uint32, + input_family=config["input_uint32"], + output_products=["arr_uint32"], + ) + m.transform( + sum_list_uint32 if use_lists else sum_array_uint32, + input_family=["arr_uint32"], + output_products=config["output_uint32"], + name="sum_uint32", + ) + + # int64 + m.transform( + collectify_int64_list if use_lists else collectify_int64, + input_family=config["input_int64"], + output_products=["arr_int64"], + ) + m.transform( + sum_list_int64 if use_lists else sum_array_int64, + input_family=["arr_int64"], + output_products=config["output_int64"], + name="sum_int64", + ) + + # uint64 + m.transform( + collectify_uint64_list if use_lists else collectify_uint64, + input_family=config["input_uint64"], + output_products=["arr_uint64"], + ) + m.transform( + sum_list_uint64 if use_lists else sum_array_uint64, + input_family=["arr_uint64"], + output_products=config["output_uint64"], + name="sum_uint64", + ) + + # float32 + m.transform( + collectify_float32_list if use_lists else collectify_float32, + input_family=config["input_float32"], + output_products=["arr_float32"], + ) + m.transform( + sum_list_float if use_lists else sum_array_float32, + input_family=["arr_float32"], + output_products=config["output_float32"], + ) + + # float64 + m.transform( + collectify_float64_list if use_lists else collectify_float64, + input_family=config["input_float64"], + output_products=["arr_float64"], + ) + m.transform( + sum_list_double if use_lists else sum_array_float64, + input_family=["arr_float64"], + output_products=config["output_float64"], + ) diff --git a/test/python/verify.py b/test/python/verify.py index 4aed8c86..d6accdfb 100644 --- a/test/python/verify.py +++ b/test/python/verify.py @@ -4,6 +4,7 @@ this observer verifies its result against the expected value. """ + class Verifier: """A callable class that can assert an expected value. @@ -22,7 +23,7 @@ class Verifier: AssertionError """ - __name__ = 'verifier' + __name__ = "verifier" def __init__(self, sum_total: int): """Create a verifier object. @@ -68,5 +69,4 @@ def PHLEX_EXPERIMENTAL_REGISTER_ALGORITHMS(m, config): None """ assert_sum = Verifier(config["sum_total"]) - m.observe(assert_sum, input_family = config["input"]) - + m.observe(assert_sum, input_family=config["input"]) diff --git a/test/python/verify_extended.py b/test/python/verify_extended.py new file mode 100644 index 00000000..22681b2b --- /dev/null +++ b/test/python/verify_extended.py @@ -0,0 +1,151 @@ +"""Observers to check for various types in tests.""" + +import sys + + +class VerifierInt: + """Verify int values.""" + + __name__ = "verifier_int" + + def __init__(self, sum_total: int): + """Initialize with expected sum.""" + self._sum_total = sum_total + + def __call__(self, value: int) -> None: + """Check if value matches expected sum.""" + assert value == self._sum_total + + +class VerifierUInt: + """Verify unsigned int values.""" + + __name__ = "verifier_uint" + + def __init__(self, sum_total: int): + """Initialize with expected sum.""" + self._sum_total = sum_total + + def __call__(self, value: "unsigned int") -> None: # type: ignore # noqa: F722 + """Check if value matches expected sum.""" + assert value == self._sum_total + + +class VerifierLong: + """Verify long values.""" + + __name__ = "verifier_long" + + def __init__(self, sum_total: int): + """Initialize with expected sum.""" + self._sum_total = sum_total + + def __call__(self, value: "long") -> None: # type: ignore # noqa: F821 + """Check if value matches expected sum.""" + print(f"VerifierLong: value={value}, expected={self._sum_total}") + assert value == self._sum_total + + +class VerifierULong: + """Verify unsigned long values.""" + + __name__ = "verifier_ulong" + + def __init__(self, sum_total: int): + """Initialize with expected sum.""" + self._sum_total = sum_total + + def __call__(self, value: "unsigned long") -> None: # type: ignore # noqa: F722 + """Check if value matches expected sum.""" + print(f"VerifierULong: value={value}, expected={self._sum_total}") + assert value == self._sum_total + + +class VerifierFloat: + """Verify float values.""" + + __name__ = "verifier_float" + + def __init__(self, sum_total: float): + """Initialize with expected sum.""" + self._sum_total = sum_total + + def __call__(self, value: "float") -> None: + """Check if value matches expected sum.""" + sys.stderr.write(f"VerifierFloat: value={value}, expected={self._sum_total}\n") + assert abs(value - self._sum_total) < 1e-5 + + +class VerifierDouble: + """Verify double values.""" + + __name__ = "verifier_double" + + def __init__(self, sum_total: float): + """Initialize with expected sum.""" + self._sum_total = sum_total + + def __call__(self, value: "double") -> None: # type: ignore # noqa: F821 + """Check if value matches expected sum.""" + print(f"VerifierDouble: value={value}, expected={self._sum_total}") + assert abs(value - self._sum_total) < 1e-5 + + +class VerifierBool: + """Verify bool values.""" + + __name__ = "verifier_bool" + + def __init__(self, expected: bool): + """Initialize with expected value.""" + self._expected = expected + + def __call__(self, value: bool) -> None: + """Check if value matches expected.""" + print(f"VerifierBool: value={value}, expected={self._expected}") + assert value == self._expected + + +def PHLEX_EXPERIMENTAL_REGISTER_ALGORITHMS(m, config): + """Register observers for the test.""" + try: + m.observe(VerifierInt(config["sum_total"]), input_family=config["input_int"]) + except (KeyError, TypeError): + # Optional configuration, skip if missing + pass + + try: + m.observe(VerifierBool(config["expected_bool"]), input_family=config["input_bool"]) + except (KeyError, TypeError): + # Optional configuration, skip if missing + pass + + try: + m.observe(VerifierUInt(config["sum_total"]), input_family=config["input_uint"]) + except (KeyError, TypeError): + # Optional configuration, skip if missing + pass + + try: + m.observe(VerifierLong(config["sum_total"]), input_family=config["input_long"]) + except (KeyError, TypeError): + # Optional configuration, skip if missing + pass + + try: + m.observe(VerifierULong(config["sum_total"]), input_family=config["input_ulong"]) + except (KeyError, TypeError): + # Optional configuration, skip if missing + pass + + try: + m.observe(VerifierFloat(config["sum_total"]), input_family=config["input_float"]) + except (KeyError, TypeError): + # Optional configuration, skip if missing + pass + + try: + m.observe(VerifierDouble(config["sum_total"]), input_family=config["input_double"]) + except (KeyError, TypeError): + # Optional configuration, skip if missing + pass