Skip to content

Commit a4e62f5

Browse files
yashhzdamilcarlucas
authored andcommitted
fix(params): wrap evaluator exception surface behind ConfigurationStepEvalError
compute_parameters wrapped the call to safe_evaluate in except (ZeroDivisionError, ValueError), which only covered math errors. Expressions could also raise: - simpleeval.NameNotDefined / InvalidExpression for undefined names and disallowed constructs - KeyError from fc_parameters['MISSING'] subscript lookups - TypeError from mismatched operand types - AttributeError, OverflowError, SyntaxError for malformed input Those fell through to a broader outer except that returned the generic "could not be computed" message for any non-math failure, and in some shapes (for example KeyError on a missing fc_parameters key) surfaced no useful detail at all. Typos in a single derived or forced parameter expression could therefore abort a step with an opaque error or, if an exception type was not in either tuple, crash the GUI. Move the exception-surface concern inside safe_evaluate itself by catching the expected set of runtime errors and re-raising a new ConfigurationStepEvalError whose message includes the original exception's class name and arguments. The original exception is preserved as __cause__ for debuggers and tracebacks. compute_parameters now catches a single domain exception (ConfigurationStepEvalError) and surfaces the wrapped message in a "could not be evaluated: <ClassName>: <details>" error, so users get the specific failure (for example "KeyError: 'DOES_NOT_EXIST'") right next to the offending parameter name. InvalidExpression is removed from the outer catch because it can no longer reach that block. Add focused unit tests in tests/test_data_model_safe_evaluator.py covering the wrapping behaviour (undefined name, missing dict key, zero division, math domain error, type error, disallowed feature) and the successful paths. Extend tests/test_backend_filesystem_configuration_steps.py with cases exercising an undefined fc_parameters key and an operand type mismatch end-to-end through compute_parameters. Closes #1526 Signed-off-by: Yash Goel <yashvardhan664@gmail.com>
1 parent 3656eaf commit a4e62f5

4 files changed

Lines changed: 202 additions & 14 deletions

File tree

ardupilot_methodic_configurator/backend_filesystem_configuration_steps.py

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,10 @@
2121
# from logging import debug as logging_debug
2222
from jsonschema import validate as json_validate
2323
from jsonschema.exceptions import ValidationError
24-
from simpleeval import InvalidExpression
2524

2625
from ardupilot_methodic_configurator import _
2726
from ardupilot_methodic_configurator.data_model_par_dict import Par, ParDict
28-
from ardupilot_methodic_configurator.data_model_safe_evaluator import safe_evaluate
27+
from ardupilot_methodic_configurator.data_model_safe_evaluator import ConfigurationStepEvalError, safe_evaluate
2928

3029

3130
class PhaseData(TypedDict, total=False):
@@ -204,13 +203,15 @@ def log_parameter_error(
204203

205204
try:
206205
result = safe_evaluate(str(parameter_info["New Value"]), variables)
207-
except (ZeroDivisionError, ValueError):
208-
# Handle math errors like:
209-
# - ZeroDivisionError: division by zero or 0.0 raised to negative power
210-
# - ValueError: math domain error (e.g., Diameter_inches**-0.838 when Diameter_inches is 0)
206+
except ConfigurationStepEvalError as _eval_err:
207+
# safe_evaluate wraps the full exception surface (malformed
208+
# expression, undefined name, missing dict key, math error,
209+
# type mismatch, overflow) into a single domain exception so
210+
# the error can be surfaced here with useful diagnostics
211+
# without crashing the configuration-step load.
211212
error_msg = _(
212213
"In file '{self.configuration_steps_filename}': '{filename}' {parameter_type} "
213-
"parameter '{parameter}' evaluation resulted in math error: {math_error}"
214+
"parameter '{parameter}' could not be evaluated: {_eval_err}"
214215
)
215216
error_msg = error_msg.format(**locals())
216217
log_parameter_error(parameter_type, ignore_fc_derived_param_warnings, errors, error_msg)
@@ -248,7 +249,7 @@ def log_parameter_error(
248249
destination[filename] = ParDict()
249250
change_reason = _(parameter_info["Change Reason"]) if parameter_info["Change Reason"] else ""
250251
destination[filename][parameter] = Par(float(result), change_reason)
251-
except (SyntaxError, NameError, KeyError, StopIteration, InvalidExpression) as _e:
252+
except (SyntaxError, NameError, KeyError, StopIteration) as _e:
252253
error_msg = _(
253254
"In file '{self.configuration_steps_filename}': '{filename}' {parameter_type} "
254255
"parameter '{parameter}' could not be computed: {_e}"

ardupilot_methodic_configurator/data_model_safe_evaluator.py

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,9 @@
1313

1414
from math import log
1515
from types import MappingProxyType
16-
from typing import Union
16+
from typing import Union, cast
1717

18-
from simpleeval import simple_eval
18+
from simpleeval import InvalidExpression, simple_eval
1919

2020
SAFE_FUNCTIONS = MappingProxyType(
2121
{
@@ -29,6 +29,19 @@
2929
)
3030

3131

32+
class ConfigurationStepEvalError(Exception):
33+
"""
34+
Raised when evaluating a configuration step expression fails.
35+
36+
Wraps the full exception surface of :func:`safe_evaluate` so callers
37+
can catch a single domain-level exception instead of enumerating
38+
every error type that simpleeval, Python arithmetic, or dict
39+
lookups inside the evaluated expression can raise. The original
40+
exception is preserved as ``__cause__`` and its class name is
41+
included in the string form for diagnostics.
42+
"""
43+
44+
3245
def safe_evaluate(expression: str, variables: dict) -> Union[int, float, str]:
3346
"""
3447
Evaluate a parameter expression safely using simpleeval.
@@ -46,8 +59,25 @@ def safe_evaluate(expression: str, variables: dict) -> Union[int, float, str]:
4659
The result of evaluating the expression.
4760
4861
Raises:
49-
InvalidExpression: If the expression uses a disallowed feature,
50-
calls an unknown function, or references an undefined variable.
62+
ConfigurationStepEvalError: If the expression is malformed,
63+
references an undefined name or missing dict key, performs a
64+
disallowed operation, or triggers a runtime error
65+
(ZeroDivisionError, OverflowError, ValueError, TypeError,
66+
AttributeError) while evaluating. The original exception is
67+
attached as ``__cause__``.
5168
5269
"""
53-
return simple_eval(expression, names=variables, functions=SAFE_FUNCTIONS)
70+
try:
71+
return cast("Union[int, float, str]", simple_eval(expression, names=variables, functions=SAFE_FUNCTIONS))
72+
except (
73+
InvalidExpression,
74+
SyntaxError,
75+
KeyError,
76+
TypeError,
77+
AttributeError,
78+
ZeroDivisionError,
79+
OverflowError,
80+
ValueError,
81+
) as e:
82+
msg = f"{type(e).__name__}: {e}"
83+
raise ConfigurationStepEvalError(msg) from e

tests/test_backend_filesystem_configuration_steps.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,27 @@ def test_compute_parameters_with_invalid_expression(self) -> None:
9999
file_info = {"forced_parameters": {"PARAM1": {"New Value": "invalid_expression", "Change Reason": "Test reason"}}}
100100
variables: dict[str, dict] = {"doc_dict": {"PARAM1": {"values": {10: "value"}}}}
101101
result = self.config_steps.compute_parameters("test_file", file_info, "forced", variables)
102-
assert "could not be computed" in result
102+
assert "could not be evaluated" in result
103+
assert "NameNotDefined" in result
104+
105+
def test_compute_parameters_with_undefined_fc_parameter_key(self) -> None:
106+
"""A forced expression that subscripts a missing fc_parameters key is reported cleanly."""
107+
file_info = {
108+
"forced_parameters": {"PARAM1": {"New Value": "fc_parameters['DOES_NOT_EXIST'] * 2", "Change Reason": "Test"}}
109+
}
110+
variables: dict[str, dict] = {"doc_dict": {"PARAM1": {"values": {}}}, "fc_parameters": {"OTHER": 1.0}}
111+
result = self.config_steps.compute_parameters("test_file", file_info, "forced", variables)
112+
assert "could not be evaluated" in result
113+
assert "KeyError" in result
114+
assert "DOES_NOT_EXIST" in result
115+
116+
def test_compute_parameters_with_type_error_in_expression(self) -> None:
117+
"""A forced expression that mixes incompatible operand types is reported cleanly."""
118+
file_info = {"forced_parameters": {"PARAM1": {"New Value": "'abc' + 1", "Change Reason": "Test"}}}
119+
variables: dict[str, dict] = {"doc_dict": {"PARAM1": {"values": {}}}}
120+
result = self.config_steps.compute_parameters("test_file", file_info, "forced", variables)
121+
assert "could not be evaluated" in result
122+
assert "TypeError" in result
103123

104124
def test_compute_parameters_with_missing_doc_dict(self) -> None:
105125
file_info = {"forced_parameters": {"PARAM1": {"New Value": "10", "Change Reason": "Test reason"}}}
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
#!/usr/bin/env python3
2+
3+
"""
4+
Tests for data_model_safe_evaluator.
5+
6+
This file is part of ArduPilot Methodic Configurator. https://github.com/ArduPilot/MethodicConfigurator
7+
8+
SPDX-FileCopyrightText: 2024-2026 Amilcar do Carmo Lucas <amilcar.lucas@iav.de>
9+
10+
SPDX-License-Identifier: GPL-3.0-or-later
11+
"""
12+
13+
import pytest
14+
from simpleeval import NameNotDefined
15+
16+
from ardupilot_methodic_configurator.data_model_safe_evaluator import (
17+
ConfigurationStepEvalError,
18+
safe_evaluate,
19+
)
20+
21+
22+
class TestSafeEvaluateSuccess:
23+
"""Test that successful expressions still return the expected value."""
24+
25+
def test_evaluates_literal_integer(self) -> None:
26+
"""
27+
A bare integer literal is returned unchanged.
28+
29+
GIVEN: The expression '42' and no variables
30+
WHEN: safe_evaluate is called
31+
THEN: The integer 42 is returned
32+
"""
33+
assert safe_evaluate("42", {}) == 42
34+
35+
def test_evaluates_arithmetic_with_variables(self) -> None:
36+
"""
37+
An arithmetic expression using supplied variables is evaluated correctly.
38+
39+
GIVEN: The expression 'a * b + c' and variables {a: 2, b: 3, c: 4}
40+
WHEN: safe_evaluate is called
41+
THEN: The result 10 is returned
42+
"""
43+
assert safe_evaluate("a * b + c", {"a": 2, "b": 3, "c": 4}) == 10
44+
45+
def test_evaluates_fc_parameters_subscript(self) -> None:
46+
"""
47+
Subscript access into fc_parameters returns the looked-up value.
48+
49+
GIVEN: An fc_parameters dict with a numeric entry
50+
WHEN: safe_evaluate indexes it by key
51+
THEN: The stored value is returned
52+
"""
53+
assert safe_evaluate("fc_parameters['INS_GYRO_FILTER']", {"fc_parameters": {"INS_GYRO_FILTER": 20.0}}) == 20.0
54+
55+
56+
class TestSafeEvaluateWrapsExceptions:
57+
"""Test that the full exception surface is wrapped in ConfigurationStepEvalError."""
58+
59+
def test_wraps_undefined_name(self) -> None:
60+
"""
61+
Referencing a name that is not in the variables dict raises ConfigurationStepEvalError.
62+
63+
GIVEN: An expression referencing an undefined bare name
64+
WHEN: safe_evaluate is called
65+
THEN: ConfigurationStepEvalError is raised
66+
AND: The original NameNotDefined is preserved as __cause__
67+
"""
68+
with pytest.raises(ConfigurationStepEvalError) as excinfo:
69+
safe_evaluate("UNKNOWN_NAME + 1", {})
70+
assert "NameNotDefined" in str(excinfo.value)
71+
assert isinstance(excinfo.value.__cause__, NameNotDefined)
72+
73+
def test_wraps_missing_dict_key(self) -> None:
74+
"""
75+
A subscript into a dict with a missing key raises ConfigurationStepEvalError.
76+
77+
GIVEN: fc_parameters does not contain 'MISSING'
78+
WHEN: safe_evaluate tries to subscript fc_parameters['MISSING']
79+
THEN: ConfigurationStepEvalError is raised
80+
AND: The original KeyError is preserved as __cause__
81+
AND: The missing key is mentioned in the error message
82+
"""
83+
with pytest.raises(ConfigurationStepEvalError) as excinfo:
84+
safe_evaluate("fc_parameters['MISSING']", {"fc_parameters": {}})
85+
assert "KeyError" in str(excinfo.value)
86+
assert "MISSING" in str(excinfo.value)
87+
assert isinstance(excinfo.value.__cause__, KeyError)
88+
89+
def test_wraps_zero_division(self) -> None:
90+
"""
91+
Dividing by zero inside an expression raises ConfigurationStepEvalError.
92+
93+
GIVEN: A denominator of zero in the expression
94+
WHEN: safe_evaluate is called
95+
THEN: ConfigurationStepEvalError is raised wrapping ZeroDivisionError
96+
"""
97+
with pytest.raises(ConfigurationStepEvalError) as excinfo:
98+
safe_evaluate("1 / 0", {})
99+
assert "ZeroDivisionError" in str(excinfo.value)
100+
assert isinstance(excinfo.value.__cause__, ZeroDivisionError)
101+
102+
def test_wraps_math_domain_error(self) -> None:
103+
"""
104+
A math domain error (log of zero) raises ConfigurationStepEvalError.
105+
106+
GIVEN: log(0), which is mathematically undefined
107+
WHEN: safe_evaluate is called
108+
THEN: ConfigurationStepEvalError is raised wrapping ValueError
109+
"""
110+
with pytest.raises(ConfigurationStepEvalError) as excinfo:
111+
safe_evaluate("log(0)", {})
112+
assert "ValueError" in str(excinfo.value)
113+
assert isinstance(excinfo.value.__cause__, ValueError)
114+
115+
def test_wraps_type_error(self) -> None:
116+
"""
117+
An operand type mismatch raises ConfigurationStepEvalError.
118+
119+
GIVEN: A string added to an integer
120+
WHEN: safe_evaluate is called
121+
THEN: ConfigurationStepEvalError is raised wrapping TypeError
122+
"""
123+
with pytest.raises(ConfigurationStepEvalError) as excinfo:
124+
safe_evaluate("'abc' + 1", {})
125+
assert "TypeError" in str(excinfo.value)
126+
assert isinstance(excinfo.value.__cause__, TypeError)
127+
128+
def test_wraps_disallowed_feature(self) -> None:
129+
"""
130+
Using a non-whitelisted function raises ConfigurationStepEvalError.
131+
132+
GIVEN: An expression that calls a function not in SAFE_FUNCTIONS
133+
WHEN: safe_evaluate is called
134+
THEN: ConfigurationStepEvalError is raised
135+
"""
136+
with pytest.raises(ConfigurationStepEvalError):
137+
safe_evaluate("open('/etc/passwd')", {})

0 commit comments

Comments
 (0)