diff --git a/flow360/component/simulation/services.py b/flow360/component/simulation/services.py index 24d183be5..eb57c0d42 100644 --- a/flow360/component/simulation/services.py +++ b/flow360/component/simulation/services.py @@ -602,7 +602,12 @@ def dict_preprocessing(params_as_dict: dict) -> dict: except pd.ValidationError as err: validation_errors = err.errors() except Exception as err: # pylint: disable=broad-exception-caught - validation_errors = handle_generic_exception(err, validation_errors) + import traceback # pylint: disable=import-outside-toplevel + + stack = traceback.format_exc() + validation_errors = handle_generic_exception( + err, validation_errors, loc_prefix=None, error_stack=stack + ) finally: if validation_context is not None: validation_warnings = list(validation_context.validation_warnings) @@ -645,8 +650,44 @@ def clean_unrelated_setting_from_params_dict(params: dict, root_item_type: str) return params +def _sanitize_stack_trace(stack: str) -> str: + """ + Sanitize file paths in stack trace to only show paths starting from 'flow360/'. + + Gracefully returns the original stack if sanitization fails. + + Parameters + ---------- + stack : str + The original stack trace string. + + Returns + ------- + str + The sanitized stack trace with shortened file paths, or the original + stack if sanitization fails. + """ + # pylint: disable=import-outside-toplevel + import re + + try: + # Remove the "Traceback (most recent call last):\n" prefix + stack = re.sub(r"^Traceback \(most recent call last\):\n\s*", "", stack) + + # Pattern to match file paths containing 'flow360/' + # Captures everything before 'flow360/' and replaces with just 'flow360/' + pattern = r'File "[^"]*[/\\](flow360[/\\][^"]*)"' + replacement = r'File "\1"' + return re.sub(pattern, replacement, stack) + except Exception: # pylint: disable=broad-exception-caught + return stack + + def handle_generic_exception( - err: Exception, validation_errors: Optional[list], loc_prefix: Optional[list[str]] = None + err: Exception, + validation_errors: Optional[list], + loc_prefix: Optional[list[str]] = None, + error_stack: Optional[str] = None, ) -> list: """ Handles generic exceptions during validation, adding to validation errors. @@ -659,6 +700,8 @@ def handle_generic_exception( Current list of validation errors, may be None. loc_prefix : list or None Prefix of the location of the generic error to help locate the issue + error_stack : str or None + The error stack trace, if available. Returns ------- @@ -668,14 +711,17 @@ def handle_generic_exception( if validation_errors is None: validation_errors = [] - validation_errors.append( - { - "type": err.__class__.__name__.lower().replace("error", "_error"), - "loc": ["unknown"] if loc_prefix is None else loc_prefix, - "msg": str(err), - "ctx": {}, - } - ) + error_entry = { + "type": err.__class__.__name__.lower().replace("error", "_error"), + "loc": ["unknown"] if loc_prefix is None else loc_prefix, + "msg": str(err), + "ctx": {}, + } + + if error_stack is not None: + error_entry["debug"] = _sanitize_stack_trace(error_stack) + + validation_errors.append(error_entry) return validation_errors diff --git a/tests/simulation/service/test_services_v2.py b/tests/simulation/service/test_services_v2.py index 34ff8202e..776a1a370 100644 --- a/tests/simulation/service/test_services_v2.py +++ b/tests/simulation/service/test_services_v2.py @@ -1533,6 +1533,75 @@ def check_setting_preserved( ) +def test_sanitize_stack_trace(): + """Test that _sanitize_stack_trace properly sanitizes file paths and removes traceback prefix.""" + from flow360.component.simulation.services import _sanitize_stack_trace + + # Test case 1: Full stack trace with traceback prefix and absolute paths + input_stack = """Traceback (most recent call last): + File "/disk2/ben/Flow360-R2/flow360/component/simulation/services.py", line 553, in validate_model + validation_info = ParamsValidationInfo( + File "/disk2/ben/Flow360-R2/flow360/component/simulation/validation/validation_context.py", line 437, in __init__ + self.farfield_method = self._get_farfield_method_(param_as_dict=param_as_dict) + File "/disk2/ben/Flow360-R2/flow360/component/simulation/validation/validation_context.py", line 162, in _get_farfield_method_ + if meshing["type_name"] == "MeshingParams": +KeyError: 'type_name'""" + + expected_output = """File "flow360/component/simulation/services.py", line 553, in validate_model + validation_info = ParamsValidationInfo( + File "flow360/component/simulation/validation/validation_context.py", line 437, in __init__ + self.farfield_method = self._get_farfield_method_(param_as_dict=param_as_dict) + File "flow360/component/simulation/validation/validation_context.py", line 162, in _get_farfield_method_ + if meshing["type_name"] == "MeshingParams": +KeyError: 'type_name'""" + + result = _sanitize_stack_trace(input_stack) + assert result == expected_output + + # Test case 2: Stack trace without traceback prefix (already sanitized prefix) + input_stack_no_prefix = """File "/home/user/projects/flow360/component/simulation/services.py", line 100, in some_function + some_code()""" + + expected_no_prefix = """File "flow360/component/simulation/services.py", line 100, in some_function + some_code()""" + + result_no_prefix = _sanitize_stack_trace(input_stack_no_prefix) + assert result_no_prefix == expected_no_prefix + + # Test case 3: Stack trace with non-flow360 paths should remain unchanged for those paths + input_mixed = """Traceback (most recent call last): + File "/usr/lib/python3.10/site-packages/pydantic/main.py", line 100, in validate + return cls.model_validate(obj) + File "/disk2/ben/Flow360-R2/flow360/component/simulation/services.py", line 50, in my_func + do_something()""" + + expected_mixed = """File "/usr/lib/python3.10/site-packages/pydantic/main.py", line 100, in validate + return cls.model_validate(obj) + File "flow360/component/simulation/services.py", line 50, in my_func + do_something()""" + + result_mixed = _sanitize_stack_trace(input_mixed) + assert result_mixed == expected_mixed + + # Test case 4: Empty string should return empty string + assert _sanitize_stack_trace("") == "" + + # Test case 5: String with no file paths should remain unchanged (except traceback prefix) + input_no_paths = "Some error message without file paths" + assert _sanitize_stack_trace(input_no_paths) == input_no_paths + + # Test case 6: Windows-style paths + input_windows = """Traceback (most recent call last): + File "C:\\Users\\dev\\Flow360-R2\\flow360\\component\\simulation\\services.py", line 100, in func + code()""" + + expected_windows = """File "flow360\\component\\simulation\\services.py", line 100, in func + code()""" + + result_windows = _sanitize_stack_trace(input_windows) + assert result_windows == expected_windows + + def test_validate_error_location_with_selector(): """ Test that validation error locations are correctly preserved when errors occur