diff --git a/.github/workflows/python_wheel_build.yml b/.github/workflows/python_wheel_build.yml index b846b85d9c9f9..a9911aa3c8ba5 100644 --- a/.github/workflows/python_wheel_build.yml +++ b/.github/workflows/python_wheel_build.yml @@ -11,7 +11,7 @@ on: schedule: - cron: '01 1 * * *' pull_request: - types: [labeled] + types: [opened, synchronize, reopened, labeled] concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} @@ -26,6 +26,7 @@ jobs: contains(github.event.pull_request.labels.*.name, 'build-python-wheels') runs-on: ubuntu-latest strategy: + fail-fast: false matrix: target: [cp39-manylinux_x86_64, cp310-manylinux_x86_64, cp311-manylinux_x86_64, cp312-manylinux_x86_64, cp313-manylinux_x86_64] name: ${{ matrix.target }} @@ -35,6 +36,44 @@ jobs: with: build-tag: ${{ matrix.target }} + test-wheels: + needs: build-wheels + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + name: test-wheel-cp${{ matrix.python-version }} + steps: + - uses: actions/checkout@v4 + + - name: Download produced wheels + uses: actions/download-artifact@v4 + with: + path: wheels + merge-multiple: true + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install produced wheel + run: | + ls -R wheels + PY_VER=$(python -c "import sys; print(f'cp{sys.version_info.major}{sys.version_info.minor}')") + WHEEL=$(ls wheels/*${PY_VER}*.whl | head -n 1) + echo "Python version: ${PY_VER}, installing wheel: ${WHEEL}" + pip install "$WHEEL" + + - name: Install tutorials dependencies + run: | + python -m pip install --no-cache-dir -r test/wheels/requirements-ci.txt + + - name: Run tutorials + run: | + pytest -vv --verbosity="4" -rF test/wheels + create-and-upload-wheel-registry: if: github.event_name != 'pull_request' # The secrets are not available in PR needs: build-wheels diff --git a/setup.py b/setup.py index d3522156c08d8..bbd31ff4413eb 100644 --- a/setup.py +++ b/setup.py @@ -52,7 +52,7 @@ def run(self): "-Dbuiltin_nlohmannjson=ON -Dbuiltin_tbb=ON -Dbuiltin_xrootd=ON " # builtins "-Dbuiltin_lz4=ON -Dbuiltin_lzma=ON -Dbuiltin_zstd=ON -Dbuiltin_xxhash=ON " # builtins "-Dpyroot=ON -Ddataframe=ON -Dxrootd=ON -Dssl=ON -Dimt=ON " - "-Droofit=ON " + "-Droofit=ON -Dmathmore=ON -Dbuiltin_fftw3=ON -Dbuiltin_gsl=ON " # Next 4 paths represent the structure of the target binaries/headers/libs # as the target installation directory of the Python environment would expect f"-DCMAKE_INSTALL_BINDIR={ROOT_BUILD_INTERNAL_DIRNAME}/ROOT/bin " diff --git a/test/wheels/requirements-ci.txt b/test/wheels/requirements-ci.txt new file mode 100644 index 0000000000000..707de5430cb0e --- /dev/null +++ b/test/wheels/requirements-ci.txt @@ -0,0 +1,26 @@ +# ROOT requirements for third-party Python packages for Python wheels tests + +# PyROOT: Interoperability with numpy arrays +numpy +pandas + +# PyROOT: ROOT.Numba.Declare decorator +numba +cffi + +# Distributed RDataFrame +pyspark # Spark backend +dask>=2022.08.1 # Dask backend +distributed>=2022.08.1 # Dask backend + +# Unified Histogram Interface (UHI) +uhi +matplotlib +mplhep + +# For testing +pytest + +# Other +scikit-learn +xgboost \ No newline at end of file diff --git a/test/wheels/test_tutorials.py b/test/wheels/test_tutorials.py new file mode 100644 index 0000000000000..cf1db22862762 --- /dev/null +++ b/test/wheels/test_tutorials.py @@ -0,0 +1,116 @@ +import os +import pathlib +import shutil +import signal +import subprocess +import sys + +import pytest +import ROOT + +ROOT.gROOT.SetBatch(True) + +tutorial_dir = pathlib.Path(str(ROOT.gROOT.GetTutorialDir())) + +subdirs = ["analysis/dataframe", "analysis/tree", "hist", "io/ntuple", "roofit/roofit"] + +SKIP_TUTORIALS = { + "ntpl004_dimuon.C", # requires reading remote data via HTTP + "ntpl008_import.C", # requires reading remote data via HTTP + "ntpl011_global_temperatures.C", # requires reading remote data via HTTP + "distrdf004_dask_lxbatch.py", # only works on lxplus + "_SQlite", # requires SQLite, not supported yet in ROOT wheels + "h1analysisProxy.C", # helper macro, not meant to run standalone + "hist001_RHist_basics.C", # required RHist, not supported in ROOT wheels + "hist002_RHist_weighted.C", # required RHist, not supported in ROOT wheels + "rf618_mixture_models.py", # fails on CI, to investigate + "rf615_simulation_based_inference.py", # fails on CI, to investigate +} + +# ---------------------- +# Python tutorials tests +# ---------------------- +py_tutorials = [] +for sub in subdirs: + sub_path = tutorial_dir / sub + for f in sub_path.rglob("*.py"): + if any(skip in f.name for skip in SKIP_TUTORIALS): + print("Skipping Python tutorial:", f) + continue + py_tutorials.append(f) + +py_tutorials = sorted(py_tutorials, key=lambda p: p.name) + + +def test_tutorials_are_detected(): + assert len(py_tutorials) > 0 + + +@pytest.mark.parametrize("tutorial", py_tutorials, ids=lambda p: p.name) +def test_tutorial(tutorial): + env = dict(**os.environ) + # force matplotlib to use a non-GUI backend + env["MPLBACKEND"] = "Agg" + print("Test env:", env) + try: + result = subprocess.run( + [sys.executable, str(tutorial)], + check=True, + env=env, + timeout=60, + capture_output=True, + text=True, + ) + print("Test stderr:", result.stderr) + + except subprocess.TimeoutExpired: + pytest.skip(f"Tutorial {tutorial} timed out") + + except subprocess.CalledProcessError as e: + # read stderr to see if EOFError occurred + if "EOFError" in e.stderr: + pytest.skip(f"Skipping {tutorial.name} (requires user input)") + raise + + +# ---------------------- +# C++ tutorials tests +# ---------------------- +cpp_tutorials = [] +for sub in subdirs: + sub_path = tutorial_dir / sub + for f in sub_path.rglob("*.C"): + if any(skip in f.name for skip in SKIP_TUTORIALS): + print("Skipping C++ tutorial:", f) + continue + cpp_tutorials.append(f) + +cpp_tutorials = sorted(cpp_tutorials, key=lambda p: p.name) + + +def test_cpp_tutorials_are_detected(): + assert len(cpp_tutorials) > 0 + + +@pytest.mark.parametrize("tutorial", cpp_tutorials, ids=lambda p: p.name) +def test_cpp_tutorial(tutorial): + try: + root_exe = shutil.which("root") + result = subprocess.run( + [root_exe, "-b", "-q", str(tutorial)], + check=True, + timeout=60, + capture_output=True, + text=True, + ) + print("Test stderr:", result.stderr) + + except subprocess.TimeoutExpired: + pytest.skip(f"Tutorial {tutorial} timed out") + + except subprocess.CalledProcessError as e: + if e.returncode == -signal.SIGILL or e.returncode == 132: + pytest.fail(f"Failing {tutorial.name} (illegal instruction on this platform)") + elif "EOFError" in e.stderr: + pytest.skip(f"Skipping {tutorial.name} (requires user input)") + raise diff --git a/tutorials/CMakeLists.txt b/tutorials/CMakeLists.txt index c26a8925335f2..46a319210602a 100644 --- a/tutorials/CMakeLists.txt +++ b/tutorials/CMakeLists.txt @@ -551,13 +551,6 @@ set(returncode_1 math/fit/fit2a.C visualisation/graphics/tmathtext.C visualisation/graphics/tmathtext2.C visualisation/graphs/gr106_exclusiongraph.C visualisation/graphs/gr016_struct.C - hist/hist102_TH2_contour_list.C - hist/hist006_TH1_bar_charts.C - hist/hist037_TH2Poly_boxes.C - hist/hist060_TH1_stats.C - hist/hist014_TH1_cumulative.C - hist/hist004_TH1_labels.C - hist/hist036_TH2_labels.C analysis/tree/h1analysis.C math/chi2test.C math/r/SimpleFitting.C) diff --git a/tutorials/hist/hist004_TH1_labels.C b/tutorials/hist/hist004_TH1_labels.C index fab569c36b1ff..4cfc837a6eb19 100644 --- a/tutorials/hist/hist004_TH1_labels.C +++ b/tutorials/hist/hist004_TH1_labels.C @@ -11,7 +11,7 @@ /// \date November 2024 /// \author Rene Brun -TCanvas *hist004_TH1_labels() +void hist004_TH1_labels() { // Create the histogram const std::array people{"Jean", "Pierre", "Marie", "Odile", "Sebastien", "Fons", "Rene", @@ -56,6 +56,4 @@ TCanvas *hist004_TH1_labels() pt->AddText(" \">\" to sort by decreasing values"); pt->AddText(" \"<\" to sort by increasing values"); pt->Draw(); - - return c1; } diff --git a/tutorials/hist/hist006_TH1_bar_charts.C b/tutorials/hist/hist006_TH1_bar_charts.C index 28b53ea6716b6..f53b41cb6d1aa 100644 --- a/tutorials/hist/hist006_TH1_bar_charts.C +++ b/tutorials/hist/hist006_TH1_bar_charts.C @@ -9,7 +9,7 @@ /// \date November 2024 /// \author Rene Brun -TCanvas *hist006_TH1_bar_charts() +void hist006_TH1_bar_charts() { // Try to open first the file cernstaff.root in tutorials/io/tree directory TString filedir = gROOT->GetTutorialDir(); @@ -28,14 +28,14 @@ TCanvas *hist006_TH1_bar_charts() auto file = std::unique_ptr(TFile::Open(filename, "READ")); if (!file) { Error("hist006_TH1_bar_charts", "file cernstaff.root not found"); - return nullptr; + return; } // Retrieve the TTree named "T" contained in the file auto tree = file->Get("T"); if (!tree) { Error("hist006_TH1_bar_charts", "Tree T is not present in file %s", file->GetName()); - return nullptr; + return; } tree->SetFillColor(45); @@ -88,6 +88,4 @@ TCanvas *hist006_TH1_bar_charts() legend->Draw(); c1->cd(); - - return c1; } diff --git a/tutorials/hist/hist014_TH1_cumulative.C b/tutorials/hist/hist014_TH1_cumulative.C index e69654571e599..3e38ef5fe59d1 100644 --- a/tutorials/hist/hist014_TH1_cumulative.C +++ b/tutorials/hist/hist014_TH1_cumulative.C @@ -17,7 +17,7 @@ #include "TCanvas.h" #include "TRandom.h" -TCanvas *hist014_TH1_cumulative() +void hist014_TH1_cumulative() { TH1 *h = new TH1D("h", "h", 100, -5., 5.); gRandom->SetSeed(); @@ -37,6 +37,4 @@ TCanvas *hist014_TH1_cumulative() c->cd(2); hc->Draw(); c->Update(); - - return c; } diff --git a/tutorials/hist/hist036_TH2_labels.C b/tutorials/hist/hist036_TH2_labels.C index e74be29e01803..b07e2b2b7bdfd 100644 --- a/tutorials/hist/hist036_TH2_labels.C +++ b/tutorials/hist/hist036_TH2_labels.C @@ -9,7 +9,7 @@ /// \date July 2016 /// \author Rene Brun -TCanvas *hist036_TH2_labels() +void hist036_TH2_labels() { const Int_t nx = 12; const Int_t ny = 20; @@ -44,5 +44,4 @@ TCanvas *hist036_TH2_labels() pt->AddText(" \">\" to sort by decreasing values"); pt->AddText(" \"<\" to sort by increasing values"); pt->Draw(); - return c1; } diff --git a/tutorials/hist/hist037_TH2Poly_boxes.C b/tutorials/hist/hist037_TH2Poly_boxes.C index 2a896d2a422c0..52ade4ad77543 100644 --- a/tutorials/hist/hist037_TH2Poly_boxes.C +++ b/tutorials/hist/hist037_TH2Poly_boxes.C @@ -10,7 +10,7 @@ /// \date August 2016 /// \author Olivier Couet -TCanvas *hist037_TH2Poly_boxes() +void hist037_TH2Poly_boxes() { TCanvas *ch2p2 = new TCanvas("ch2p2", "ch2p2", 600, 400); gStyle->SetPalette(57); @@ -44,5 +44,4 @@ TCanvas *hist037_TH2Poly_boxes() } h2p->Draw("COLZ"); - return ch2p2; } diff --git a/tutorials/hist/hist060_TH1_stats.C b/tutorials/hist/hist060_TH1_stats.C index 06a146cfb4713..eba635da27c39 100644 --- a/tutorials/hist/hist060_TH1_stats.C +++ b/tutorials/hist/hist060_TH1_stats.C @@ -13,7 +13,7 @@ /// \date August 2016 /// \author Olivier Couet -TCanvas *hist060_TH1_stats() +void hist060_TH1_stats() { // Create and plot a test histogram with stats TCanvas *se = new TCanvas; @@ -44,5 +44,4 @@ TCanvas *hist060_TH1_stats() h->SetStats(0); se->Modified(); - return se; } diff --git a/tutorials/hist/hist102_TH2_contour_list.C b/tutorials/hist/hist102_TH2_contour_list.C index 5de6617d25c28..6ecbb016195a8 100644 --- a/tutorials/hist/hist102_TH2_contour_list.C +++ b/tutorials/hist/hist102_TH2_contour_list.C @@ -20,7 +20,7 @@ Double_t SawTooth(Double_t x, Double_t WaveLen); -TCanvas *hist102_TH2_contour_list() +void hist102_TH2_contour_list() { const Double_t PI = TMath::Pi(); @@ -91,7 +91,7 @@ TCanvas *hist102_TH2_contour_list() if (!conts) { printf("*** No Contours Were Extracted!\n"); - return nullptr; + return; } TList *contLevel = nullptr; @@ -157,7 +157,6 @@ TCanvas *hist102_TH2_contour_list() printf("\n\n\tExtracted %d Contours and %d Graphs \n", TotalConts, nGraphs); gStyle->SetTitleW(0.); gStyle->SetTitleH(0.); - return c1; } Double_t SawTooth(Double_t x, Double_t WaveLen)