From 12e703904711f06069ef985237e0376ef098ea37 Mon Sep 17 00:00:00 2001 From: Steffen Albrecht Date: Tue, 9 Jun 2026 15:20:23 +0200 Subject: [PATCH 1/9] [HS3] [RF] Adding test to pull and run external HS3TestSuite --- .../root-ci-config/buildconfig/global.txt | 1 + cmake/modules/RootBuildOptions.cmake | 3 +- roofit/hs3/test/CMakeLists.txt | 13 +++++ roofit/hs3/test/test_hs3_suite.py | 50 +++++++++++++++++++ 4 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 roofit/hs3/test/test_hs3_suite.py diff --git a/.github/workflows/root-ci-config/buildconfig/global.txt b/.github/workflows/root-ci-config/buildconfig/global.txt index 4e598d6afa5ed..464bc6c2261a5 100644 --- a/.github/workflows/root-ci-config/buildconfig/global.txt +++ b/.github/workflows/root-ci-config/buildconfig/global.txt @@ -68,6 +68,7 @@ roofit_multiprocess=OFF root7=ON rootbench=OFF roottest=ON +hs3testsuite=ON runtime_cxxmodules=ON shadowpw=OFF shared=ON diff --git a/cmake/modules/RootBuildOptions.cmake b/cmake/modules/RootBuildOptions.cmake index 0966ae13c3353..b0e6d0152e444 100644 --- a/cmake/modules/RootBuildOptions.cmake +++ b/cmake/modules/RootBuildOptions.cmake @@ -189,6 +189,7 @@ option(gminimal "Enable only required options by default, but include X11/Cocoa" option(minimal "Enable only required options by default" OFF) option(rootbench "Build rootbench if rootbench exists in root or if it is a sibling directory (implies testing=ON)" OFF) option(roottest "Build roottest (implies testing=ON)" OFF) +option(hs3testsuite "Setup and use the HS3 conformance test suite (implies testing=ON)" OFF) option(testing "Enable testing with CTest" OFF) option(asan "Build ROOT with address sanitizer instrumentation" OFF) @@ -329,7 +330,7 @@ endif() ROOT_APPLY_OPTIONS() #---roottest option implies testing -if(roottest OR rootbench) +if(roottest OR rootbench OR hs3testsuite) set(testing ON CACHE BOOL "" FORCE) endif() diff --git a/roofit/hs3/test/CMakeLists.txt b/roofit/hs3/test/CMakeLists.txt index d152337c8a7a8..1f41a041690aa 100644 --- a/roofit/hs3/test/CMakeLists.txt +++ b/roofit/hs3/test/CMakeLists.txt @@ -1,2 +1,15 @@ ROOT_ADD_GTEST(testRooFitHS3 testRooFitHS3.cxx LIBRARIES RooFitCore RooFit RooFitHS3) ROOT_ADD_GTEST(testHS3SimultaneousFit testHS3SimultaneousFit.cxx LIBRARIES RooFitCore RooFit RooFitHS3 RooStats) + +if(pyroot AND hs3testsuite) + set(hs3testsuite_dir ${CMAKE_CURRENT_SOURCE_DIR}/HS3TestSuite) + if(NOT EXISTS ${hs3testsuite_dir}) + find_package(Git QUIET REQUIRED) + execute_process(COMMAND ${GIT_EXECUTABLE} clone https://github.com/Phmonski/HS3TestSuite + WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}) + endif() + + if(EXISTS ${hs3testsuite_dir}) + ROOT_ADD_PYUNITTEST(hs3-suite test_hs3_suite.py PYTHON_DEPS jsonschema ENVIRONMENT HS3TESTSUITE_ROOT=${hs3testsuite_dir}) + endif() +endif() \ No newline at end of file diff --git a/roofit/hs3/test/test_hs3_suite.py b/roofit/hs3/test/test_hs3_suite.py new file mode 100644 index 0000000000000..4c0f653c95668 --- /dev/null +++ b/roofit/hs3/test/test_hs3_suite.py @@ -0,0 +1,50 @@ +import os +from pathlib import Path +import sys +import unittest + + +hs3_root_dir = os.environ.get("HS3TESTSUITE_ROOT") +if hs3_root_dir: + sys.path.insert(0, hs3_root_dir) + +try: + from hs3suite.runner import run_suite + +except ImportError as e: + TestHS3Suite = type( + "TestHS3Suite", + (unittest.TestCase,), + dict(test_dependencies=lambda self, e=e: self.fail(f"Missing dependency: {e}")), + ) +else: + hs3_root_path = Path(hs3_root_dir) + try: + results = run_suite(hs3_root_path, "roofit") + except Exception as e: + + def test(self, e=e): + self.fail(f"HS3TestSuite failed to run: {e}") + + test.__name__ = "test_hs3testsuite_execution" + TestHS3Suite = type("TestHS3Suite", (unittest.TestCase,), {test.__name__: test}) + else: + namespace = dict(__module__=__name__) + for r in results: + + def _make(result): + def test(self): + if result.status == "failed": + self.fail(result.message) + return + + test.__name__ = f"test_{result.test_id}__{result.check_id}" + test.__doc__ = f"{result.test_id}::{result.check_id}" + return test + + func = _make(r) + namespace[func.__name__] = func + TestHS3Suite = type("TestHS3Suite", (unittest.TestCase,), namespace) + +if __name__ == "__main__": + unittest.main() From b02d03b16f1d99bf07e461adb428f4fcc6c18156 Mon Sep 17 00:00:00 2001 From: Steffen Albrecht Date: Thu, 11 Jun 2026 06:19:35 +0200 Subject: [PATCH 2/9] fix linting (import sorting) --- roofit/hs3/test/test_hs3_suite.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/roofit/hs3/test/test_hs3_suite.py b/roofit/hs3/test/test_hs3_suite.py index 4c0f653c95668..983de1be38a8a 100644 --- a/roofit/hs3/test/test_hs3_suite.py +++ b/roofit/hs3/test/test_hs3_suite.py @@ -1,8 +1,7 @@ import os -from pathlib import Path import sys import unittest - +from pathlib import Path hs3_root_dir = os.environ.get("HS3TESTSUITE_ROOT") if hs3_root_dir: From 5b2bdb5a4307c4d7f24679c8f0fd4c1a834f1d32 Mon Sep 17 00:00:00 2001 From: Steffen Albrecht Date: Tue, 16 Jun 2026 14:37:50 +0200 Subject: [PATCH 3/9] [cmake] rename hs3testsuite option to test_roofit_hs3testsuite and document it documents dependecies on network connection and other build options. Now throwing error if `pyroot` or `testing` option is set to OFF instead of implying them with hs3 option. --- .github/workflows/root-ci-config/buildconfig/global.txt | 2 +- cmake/modules/RootBuildOptions.cmake | 8 ++++++-- roofit/hs3/test/CMakeLists.txt | 2 +- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/root-ci-config/buildconfig/global.txt b/.github/workflows/root-ci-config/buildconfig/global.txt index 464bc6c2261a5..eafb656ada6f7 100644 --- a/.github/workflows/root-ci-config/buildconfig/global.txt +++ b/.github/workflows/root-ci-config/buildconfig/global.txt @@ -68,7 +68,7 @@ roofit_multiprocess=OFF root7=ON rootbench=OFF roottest=ON -hs3testsuite=ON +test_roofit_hs3testsuite=ON runtime_cxxmodules=ON shadowpw=OFF shared=ON diff --git a/cmake/modules/RootBuildOptions.cmake b/cmake/modules/RootBuildOptions.cmake index b0e6d0152e444..baa50d4ae9d25 100644 --- a/cmake/modules/RootBuildOptions.cmake +++ b/cmake/modules/RootBuildOptions.cmake @@ -189,7 +189,7 @@ option(gminimal "Enable only required options by default, but include X11/Cocoa" option(minimal "Enable only required options by default" OFF) option(rootbench "Build rootbench if rootbench exists in root or if it is a sibling directory (implies testing=ON)" OFF) option(roottest "Build roottest (implies testing=ON)" OFF) -option(hs3testsuite "Setup and use the HS3 conformance test suite (implies testing=ON)" OFF) +option(test_roofit_hs3testsuite "Setup and use the HS3 conformance test suite (requires network)" OFF) option(testing "Enable testing with CTest" OFF) option(asan "Build ROOT with address sanitizer instrumentation" OFF) @@ -330,7 +330,7 @@ endif() ROOT_APPLY_OPTIONS() #---roottest option implies testing -if(roottest OR rootbench OR hs3testsuite) +if(roottest OR rootbench) set(testing ON CACHE BOOL "" FORCE) endif() @@ -339,6 +339,10 @@ if(testing) set(testsupport ON CACHE BOOL "" FORCE) endif() +#---running HS3 test suite requires both testing and pyroot +if(test_roofit_hs3testsuite AND (NOT testing OR NOT pyroot)) + message(FATAL_ERROR "-Dtest_roofit_hs3testsuite=ON requires both -Dtesting=ON and -Dpyroot=ON)") +endif() if(unfold AND NOT xml) message(STATUS "Cannot enable unfold without enabling xml: unfold is disabled.") diff --git a/roofit/hs3/test/CMakeLists.txt b/roofit/hs3/test/CMakeLists.txt index 1f41a041690aa..af22adfca8f86 100644 --- a/roofit/hs3/test/CMakeLists.txt +++ b/roofit/hs3/test/CMakeLists.txt @@ -1,7 +1,7 @@ ROOT_ADD_GTEST(testRooFitHS3 testRooFitHS3.cxx LIBRARIES RooFitCore RooFit RooFitHS3) ROOT_ADD_GTEST(testHS3SimultaneousFit testHS3SimultaneousFit.cxx LIBRARIES RooFitCore RooFit RooFitHS3 RooStats) -if(pyroot AND hs3testsuite) +if(test_roofit_hs3testsuite) set(hs3testsuite_dir ${CMAKE_CURRENT_SOURCE_DIR}/HS3TestSuite) if(NOT EXISTS ${hs3testsuite_dir}) find_package(Git QUIET REQUIRED) From f6a0e5f051a634c3984235c75cb0aec6a006bb04 Mon Sep 17 00:00:00 2001 From: Steffen Albrecht Date: Tue, 16 Jun 2026 15:26:10 +0200 Subject: [PATCH 4/9] [CMake] Update hs3testsuite directory path to use current binary directory and pin to specific commit hash --- roofit/hs3/test/CMakeLists.txt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/roofit/hs3/test/CMakeLists.txt b/roofit/hs3/test/CMakeLists.txt index af22adfca8f86..92a44e3ee1686 100644 --- a/roofit/hs3/test/CMakeLists.txt +++ b/roofit/hs3/test/CMakeLists.txt @@ -2,11 +2,13 @@ ROOT_ADD_GTEST(testRooFitHS3 testRooFitHS3.cxx LIBRARIES RooFitCore RooFit RooFi ROOT_ADD_GTEST(testHS3SimultaneousFit testHS3SimultaneousFit.cxx LIBRARIES RooFitCore RooFit RooFitHS3 RooStats) if(test_roofit_hs3testsuite) - set(hs3testsuite_dir ${CMAKE_CURRENT_SOURCE_DIR}/HS3TestSuite) + set(hs3testsuite_dir ${CMAKE_CURRENT_BINARY_DIR}/HS3TestSuite) if(NOT EXISTS ${hs3testsuite_dir}) find_package(Git QUIET REQUIRED) execute_process(COMMAND ${GIT_EXECUTABLE} clone https://github.com/Phmonski/HS3TestSuite - WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}) + WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}) + execute_process(COMMAND ${GIT_EXECUTABLE} reset --hard 9d04e321ae6fddd283a35507f14ecf852eb7df61 + WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}) endif() if(EXISTS ${hs3testsuite_dir}) From 0d462a0f455a4d2e63f589214cb706a9be7c0ed0 Mon Sep 17 00:00:00 2001 From: Steffen Albrecht Date: Tue, 16 Jun 2026 16:51:29 +0200 Subject: [PATCH 5/9] [CMake] Use FetchContent for more robust HS3TestSuite download --- roofit/hs3/test/CMakeLists.txt | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/roofit/hs3/test/CMakeLists.txt b/roofit/hs3/test/CMakeLists.txt index 92a44e3ee1686..911c70460b6bb 100644 --- a/roofit/hs3/test/CMakeLists.txt +++ b/roofit/hs3/test/CMakeLists.txt @@ -2,16 +2,14 @@ ROOT_ADD_GTEST(testRooFitHS3 testRooFitHS3.cxx LIBRARIES RooFitCore RooFit RooFi ROOT_ADD_GTEST(testHS3SimultaneousFit testHS3SimultaneousFit.cxx LIBRARIES RooFitCore RooFit RooFitHS3 RooStats) if(test_roofit_hs3testsuite) - set(hs3testsuite_dir ${CMAKE_CURRENT_BINARY_DIR}/HS3TestSuite) - if(NOT EXISTS ${hs3testsuite_dir}) - find_package(Git QUIET REQUIRED) - execute_process(COMMAND ${GIT_EXECUTABLE} clone https://github.com/Phmonski/HS3TestSuite - WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}) - execute_process(COMMAND ${GIT_EXECUTABLE} reset --hard 9d04e321ae6fddd283a35507f14ecf852eb7df61 - WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}) - endif() - - if(EXISTS ${hs3testsuite_dir}) - ROOT_ADD_PYUNITTEST(hs3-suite test_hs3_suite.py PYTHON_DEPS jsonschema ENVIRONMENT HS3TESTSUITE_ROOT=${hs3testsuite_dir}) + include(FetchContent) + FetchContent_Declare( + hs3testsuite + GIT_REPOSITORY https://github.com/Phmonski/HS3TestSuite.git + GIT_TAG 9d04e321ae6fddd283a35507f14ecf852eb7df61 + ) + FetchContent_MakeAvailable(hs3testsuite) + if(EXISTS ${hs3testsuite_SOURCE_DIR}) + ROOT_ADD_PYUNITTEST(hs3-suite test_hs3_suite.py PYTHON_DEPS jsonschema ENVIRONMENT HS3TESTSUITE_ROOT=${hs3testsuite_SOURCE_DIR}) endif() endif() \ No newline at end of file From 09e7d2445e5a3b2d743b3f67b094c3451ba08bfb Mon Sep 17 00:00:00 2001 From: Steffen Albrecht Date: Fri, 19 Jun 2026 11:14:12 +0200 Subject: [PATCH 6/9] adding roofit backend from hs3testsuite Co-authored-by: Phmonski --- roofit/hs3/test/CMakeLists.txt | 6 +- .../hs3/test/hs3testsuite_roofit_backend.py | 98 +++++++++++++++++++ roofit/hs3/test/test_hs3_suite.py | 3 + 3 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 roofit/hs3/test/hs3testsuite_roofit_backend.py diff --git a/roofit/hs3/test/CMakeLists.txt b/roofit/hs3/test/CMakeLists.txt index 911c70460b6bb..dce0d862791dc 100644 --- a/roofit/hs3/test/CMakeLists.txt +++ b/roofit/hs3/test/CMakeLists.txt @@ -10,6 +10,10 @@ if(test_roofit_hs3testsuite) ) FetchContent_MakeAvailable(hs3testsuite) if(EXISTS ${hs3testsuite_SOURCE_DIR}) - ROOT_ADD_PYUNITTEST(hs3-suite test_hs3_suite.py PYTHON_DEPS jsonschema ENVIRONMENT HS3TESTSUITE_ROOT=${hs3testsuite_SOURCE_DIR}) + ROOT_ADD_PYUNITTEST(hs3-suite test_hs3_suite.py + PYTHON_DEPS jsonschema + ENVIRONMENT HS3TESTSUITE_ROOT=${hs3testsuite_SOURCE_DIR} + COPY_TO_BUILDDIR hs3testsuite_roofit_backend.py + ) endif() endif() \ No newline at end of file diff --git a/roofit/hs3/test/hs3testsuite_roofit_backend.py b/roofit/hs3/test/hs3testsuite_roofit_backend.py new file mode 100644 index 0000000000000..47fa56e77ded5 --- /dev/null +++ b/roofit/hs3/test/hs3testsuite_roofit_backend.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +import os +import sys +from contextlib import contextmanager +from pathlib import Path +from typing import Any + + +class RooFitBackend: + name = "roofit" + + def __init__(self) -> None: + import ROOT # type: ignore + + self.ROOT = ROOT + ROOT.gROOT.SetBatch(True) + ROOT.gErrorIgnoreLevel = ROOT.kFatal + ROOT.RooMsgService.instance().setGlobalKillBelow(ROOT.RooFit.FATAL) + + def load_workspace(self, path: Path): + ws = self.ROOT.RooWorkspace("hs3suite_ws") + tool = self.ROOT.RooJSONFactoryWSTool(ws) + with suppress_root_output(): + tool.importJSON(str(path)) + return ws + + def structure(self, workspace) -> dict[str, list[str]]: + return { + "pdfs": sorted(obj.GetName() for obj in workspace.allPdfs()), + "functions": sorted(obj.GetName() for obj in workspace.allFunctions()), + "data": sorted(obj.GetName() for obj in workspace.allData()), + } + + def run_structure_check(self, workspace, check: dict[str, Any]) -> None: + actual = self.structure(workspace) + target = check.get("target", {}) + for key in ("pdfs", "functions", "data"): + required = set(target.get(key, [])) + missing = required.difference(actual[key]) + if missing: + raise AssertionError(f"missing {key}: {sorted(missing)}") + + def run_twice_delta_nll_scan(self, workspace, check: dict[str, Any]) -> list[float]: + target = check["target"] + pdf = workspace.pdf(target["pdf"]) + if pdf is None: + raise AssertionError(f"PDF {target['pdf']!r} not found") + data = workspace.data(target["data"]) + if data is None: + raise AssertionError(f"data {target['data']!r} not found") + + self._apply_parameter_point(workspace, check["reference_point"]) + with suppress_root_output(): + nll = pdf.createNLL( + data, + self.ROOT.RooFit.NumCPU(1), + self.ROOT.RooFit.EvalBackend("legacy"), + ) + reference = float(nll.getVal()) + values = [] + scan_parameter = check["scan_parameter"] + for point in check["scan_points"]: + self._apply_parameter_point(workspace, check["reference_point"]) + var = workspace.var(scan_parameter) + if var is None: + raise AssertionError(f"scan parameter {scan_parameter!r} not found") + var.setVal(float(point)) + with suppress_root_output(): + values.append(2.0 * (float(nll.getVal()) - reference)) + return values + + def _apply_parameter_point(self, workspace, values: dict[str, float]) -> None: + for name, value in values.items(): + var = workspace.var(name) + if var is not None: + var.setVal(float(value)) + + +@contextmanager +def suppress_root_output(): + """Suppress noisy C++ diagnostics that bypass RooMsgService.""" + + sys.stdout.flush() + sys.stderr.flush() + devnull_fd = os.open(os.devnull, os.O_WRONLY) + stdout_fd = os.dup(1) + stderr_fd = os.dup(2) + try: + os.dup2(devnull_fd, 1) + os.dup2(devnull_fd, 2) + yield + finally: + os.dup2(stdout_fd, 1) + os.dup2(stderr_fd, 2) + os.close(stdout_fd) + os.close(stderr_fd) + os.close(devnull_fd) diff --git a/roofit/hs3/test/test_hs3_suite.py b/roofit/hs3/test/test_hs3_suite.py index 983de1be38a8a..fd4556c66c5f4 100644 --- a/roofit/hs3/test/test_hs3_suite.py +++ b/roofit/hs3/test/test_hs3_suite.py @@ -8,7 +8,10 @@ sys.path.insert(0, hs3_root_dir) try: + from hs3suite.backends import _BACKENDS from hs3suite.runner import run_suite + + _BACKENDS['roofit'] = ("hs3testsuite_roofit_backend","RooFitBackend") except ImportError as e: TestHS3Suite = type( From e2c4f09dee84f867b1e5f0b688c14dff8f582155 Mon Sep 17 00:00:00 2001 From: Steffen Albrecht Date: Fri, 19 Jun 2026 11:17:32 +0200 Subject: [PATCH 7/9] update HS3TestSuite repository URL to the new source --- roofit/hs3/test/CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/roofit/hs3/test/CMakeLists.txt b/roofit/hs3/test/CMakeLists.txt index dce0d862791dc..be9472f6725c7 100644 --- a/roofit/hs3/test/CMakeLists.txt +++ b/roofit/hs3/test/CMakeLists.txt @@ -5,7 +5,7 @@ if(test_roofit_hs3testsuite) include(FetchContent) FetchContent_Declare( hs3testsuite - GIT_REPOSITORY https://github.com/Phmonski/HS3TestSuite.git + GIT_REPOSITORY https://github.com/hep-statistics-serialization-standard/HS3TestSuite.git GIT_TAG 9d04e321ae6fddd283a35507f14ecf852eb7df61 ) FetchContent_MakeAvailable(hs3testsuite) From 57867630f23426efbe61356e099cfc36d064e698 Mon Sep 17 00:00:00 2001 From: Steffen Albrecht Date: Fri, 19 Jun 2026 17:22:41 +0200 Subject: [PATCH 8/9] making ruff happy --- roofit/hs3/test/test_hs3_suite.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/roofit/hs3/test/test_hs3_suite.py b/roofit/hs3/test/test_hs3_suite.py index fd4556c66c5f4..5c7804afd028d 100644 --- a/roofit/hs3/test/test_hs3_suite.py +++ b/roofit/hs3/test/test_hs3_suite.py @@ -10,8 +10,8 @@ try: from hs3suite.backends import _BACKENDS from hs3suite.runner import run_suite - - _BACKENDS['roofit'] = ("hs3testsuite_roofit_backend","RooFitBackend") + + _BACKENDS["roofit"] = ("hs3testsuite_roofit_backend", "RooFitBackend") except ImportError as e: TestHS3Suite = type( From b544a192e5005a5b477303fc7489da8c58651eac Mon Sep 17 00:00:00 2001 From: Steffen Albrecht Date: Wed, 24 Jun 2026 15:48:39 +0200 Subject: [PATCH 9/9] remove stale EXISTS guard and disable git prompt in HS3 testsuite fetching --- roofit/hs3/test/CMakeLists.txt | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/roofit/hs3/test/CMakeLists.txt b/roofit/hs3/test/CMakeLists.txt index be9472f6725c7..f5a1fea4a18b5 100644 --- a/roofit/hs3/test/CMakeLists.txt +++ b/roofit/hs3/test/CMakeLists.txt @@ -8,12 +8,12 @@ if(test_roofit_hs3testsuite) GIT_REPOSITORY https://github.com/hep-statistics-serialization-standard/HS3TestSuite.git GIT_TAG 9d04e321ae6fddd283a35507f14ecf852eb7df61 ) + # suppress git interactive prompt so fetch fails fast if the repo is gone or unreachable + set(ENV{GIT_TERMINAL_PROMPT} "0") FetchContent_MakeAvailable(hs3testsuite) - if(EXISTS ${hs3testsuite_SOURCE_DIR}) - ROOT_ADD_PYUNITTEST(hs3-suite test_hs3_suite.py - PYTHON_DEPS jsonschema - ENVIRONMENT HS3TESTSUITE_ROOT=${hs3testsuite_SOURCE_DIR} - COPY_TO_BUILDDIR hs3testsuite_roofit_backend.py - ) - endif() + ROOT_ADD_PYUNITTEST(hs3-suite test_hs3_suite.py + PYTHON_DEPS jsonschema + ENVIRONMENT HS3TESTSUITE_ROOT=${hs3testsuite_SOURCE_DIR} + COPY_TO_BUILDDIR hs3testsuite_roofit_backend.py + ) endif() \ No newline at end of file