diff --git a/openbb_platform/core/openbb_core/app/static/package_builder.py b/openbb_platform/core/openbb_core/app/static/package_builder.py index 52a81e71910..de34cd54448 100644 --- a/openbb_platform/core/openbb_core/app/static/package_builder.py +++ b/openbb_platform/core/openbb_core/app/static/package_builder.py @@ -7,8 +7,10 @@ import inspect import os import re import shutil +import signal import sys import textwrap +import traceback import typing as typing_module from collections import OrderedDict from collections.abc import Callable @@ -186,15 +188,38 @@ class PackageBuilder: lock_file.write(str(os.getpid())) lock_file.flush() - # Actual build steps - self.console.log("\nBuilding extensions package...\n") - self._clean(modules) - ext_map = self._get_extension_map() - self._save_modules(modules, ext_map) - self._save_reference_file(ext_map) - self._save_package() - if self.lint: - self._run_linters() + # Signal handler for SIGTERM + def _handle_term(signum, _): + self._clean(modules) + sys.exit(signum) + + if hasattr(signal, "SIGTERM"): + original_sigterm = signal.getsignal(signal.SIGTERM) + signal.signal(signal.SIGTERM, _handle_term) + + try: + self._clean(modules) + ext_map = self._get_extension_map() + self._save_modules(modules, ext_map) + self._save_reference_file(ext_map) + self._save_package() + if self.lint: + self._run_linters() + except BaseException as e: + if not isinstance(e, (KeyboardInterrupt, SystemExit)): + self.console.error("\nBuild failed!") # type: ignore # pylint: disable=E1101 + self.console.error(f"Error: {e}") # type: ignore # pylint: disable=E1101 + self.console.error(traceback.format_exc()) # type: ignore # pylint: disable=E1101 + self.console.error("\nInstruction:") # type: ignore # pylint: disable=E1101 + self.console.error( # type: ignore # pylint: disable=E1101 + "Set OPENBB_DEBUG_MODE='true' environment variable and run " + "'openbb-build' again to see verbose output." + ) + self._clean(modules) + raise + finally: + if hasattr(signal, "SIGTERM"): + signal.signal(signal.SIGTERM, original_sigterm) except BlockingIOError: raise RuntimeError( # noqa # pylint: disable=W0707 f"Another build process is running and has locked {self._lock_path}" diff --git a/openbb_platform/core/openbb_core/app/static/utils/console.py b/openbb_platform/core/openbb_core/app/static/utils/console.py index 4f744bcfd7c..ebe25578bfb 100644 --- a/openbb_platform/core/openbb_core/app/static/utils/console.py +++ b/openbb_platform/core/openbb_core/app/static/utils/console.py @@ -14,3 +14,7 @@ class Console: """Console log method.""" if self.verbose or Env().DEBUG_MODE: print(message, **kwargs) # noqa: T201 + + def error(self, message: str, **kwargs): + """Console error method.""" + print(message, **kwargs) # noqa: T201 diff --git a/openbb_platform/core/tests/app/static/test_package_builder.py b/openbb_platform/core/tests/app/static/test_package_builder.py index ae85f959dc6..befdce35708 100644 --- a/openbb_platform/core/tests/app/static/test_package_builder.py +++ b/openbb_platform/core/tests/app/static/test_package_builder.py @@ -988,3 +988,47 @@ def test_get_field_type_unwraps_forward_ref(docstring_generator): assert "ForwardRef" not in result2, ( f"ForwardRef should be unwrapped in docstring types, got: {result2}" ) + + +def test_build_purges_on_failure(tmp_openbb_dir): + """Test that build purges incomplete assets on failure.""" + builder = PackageBuilder(tmp_openbb_dir) + + # Mocking _save_modules to fail + with ( + patch.object(builder, "_clean") as mock_clean, + patch.object(builder.console, "error") as mock_error, + patch.object(builder, "_get_extension_map"), + patch.object(builder, "_save_modules", side_effect=Exception("Generation failed")), + ): + with pytest.raises(Exception, match="Generation failed"): + builder.build() + + # _clean should be called twice: once at the start, once after failure + assert mock_clean.call_count == 2 + # console.error should be called for error message, traceback and instruction + assert mock_error.call_count >= 3 + mock_error.assert_any_call("\nBuild failed!") + assert any("Generation failed" in str(call) for call in mock_error.call_args_list) + + +def test_build_purges_on_keyboard_interrupt(tmp_openbb_dir): + """Test that build purges incomplete assets on KeyboardInterrupt.""" + builder = PackageBuilder(tmp_openbb_dir) + + # Mocking _save_modules to raise KeyboardInterrupt + with ( + patch.object(builder, "_clean") as mock_clean, + patch.object(builder.console, "error") as mock_error, + patch.object(builder, "_get_extension_map"), + patch.object(builder, "_save_modules", side_effect=KeyboardInterrupt()), + ): + with pytest.raises(KeyboardInterrupt): + builder.build() + + # _clean should be called twice: once at the start, once after interruption + assert mock_clean.call_count == 2 + # console.error should NOT be called for KeyboardInterrupt + mock_error.assert_not_called() + +