fix(core): purge partially generated assets on build failure (#7462)

* fix(core): purge partially generated assets on build failure

* feat(core): enhance build error reporting with traceback and user instructions

* feat(core): handle interruptions and SIGTERM during build to ensure asset purge

* fix mypy

* temp pylint disable

---------

Co-authored-by: Danglewood <85772166+deeleeramone@users.noreply.github.com>
This commit is contained in:
Shreyansh
2026-04-18 04:59:58 +05:30
committed by GitHub
parent 8cc70fa1c8
commit 319657a74c
3 changed files with 82 additions and 9 deletions

View File

@@ -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,8 +188,16 @@ class PackageBuilder:
lock_file.write(str(os.getpid()))
lock_file.flush()
# Actual build steps
self.console.log("\nBuilding extensions package...\n")
# 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)
@@ -195,6 +205,21 @@ class PackageBuilder:
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}"

View File

@@ -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

View File

@@ -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()