diff --git a/src/app_model/expressions/_expressions.py b/src/app_model/expressions/_expressions.py index e765ec9a..7035ad12 100644 --- a/src/app_model/expressions/_expressions.py +++ b/src/app_model/expressions/_expressions.py @@ -153,9 +153,6 @@ class Expr(ast.AST, Generic[T]): >>> new_expr.eval(dict(v2="hello!", myvar=8)) 'hello!' - you can also use keyword arguments. This is *slightly* slower - >>> new_expr.eval(v2="hello!", myvar=4) - serialize >>> str(new_expr) 'myvar > 5 and v2' @@ -184,6 +181,7 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: if type(self).__name__ == "Expr": raise RuntimeError("Don't instantiate Expr. Use `Expr.parse`") super().__init__(*args, **kwargs) + self.eval = self.eval_with_callables # type: ignore[method-assign] self._recompile() def _recompile(self) -> None: @@ -191,21 +189,51 @@ def _recompile(self) -> None: self._code = compile(ast.Expression(body=self), "", "eval") self._names = set(self._iter_names()) - def eval( - self, context: Mapping[str, object] | None = None, **ctx_kwargs: object - ) -> T: + def eval(self, context: Mapping[str, object] | None = None) -> T: + """Evaluate this expression with names in `context`. + + Parameters + ---------- + context : Mapping[str, object] | None + Mapping of names to objects to evaluate the expression with. + """ + # will have been replaced in __init__ + raise NotImplementedError("This method should have been replaced.") + + def eval_no_callables(self, context: Mapping[str, object] | None = None) -> T: """Evaluate this expression with names in `context`.""" if context is None: - context = ctx_kwargs - elif ctx_kwargs: - context = {**context, **ctx_kwargs} + context = {} try: - return eval(self._code, {}, context) # type: ignore + return eval(self._code, {}, context) # type: ignore[no-any-return] except NameError as e: - miss = {k for k in self._names if k not in context} - raise NameError( - f"Names required to eval this expression are missing: {miss}" - ) from e + raise self._missing_names_error(context) from e + + def eval_with_callables(self, context: Mapping[str, object] | None = None) -> T: + """Evaluate this expression with names in `context`, allowing callables.""" + if context is None: + return self.eval_no_callables({}) + + # build a new context, evaluating any callables + # we only want to evaluate the callables if they are needed, so we + # build a new context with only the names in this expression. + ctx = {} + for name in self._names: + if name in context: + ctx[name] = val() if callable(val := context[name]) else val + else: + # early exit if we're missing names + raise self._missing_names_error(context) + return self.eval_no_callables(ctx) + + def _missing_names_error(self, context: Mapping[str, object]) -> NameError: + """More informative error message when names are missing.""" + miss = {k for k in self._names if k not in context} + num_keys = len(context) + return NameError( + f"Names required to eval expression '{self}' are missing: {miss}. " + f"Context has {num_keys} keys." + ) @classmethod def parse(cls, expr: str) -> Expr: diff --git a/tests/test_context/test_expressions.py b/tests/test_context/test_expressions.py index a62d69fb..71d536df 100644 --- a/tests/test_context/test_expressions.py +++ b/tests/test_context/test_expressions.py @@ -236,12 +236,6 @@ def test_safe_eval(): safe_eval("{1,2,3}") -def test_eval_kwargs(): - expr = parse_expression("a + b") - assert expr.eval(a=1, b=2) == 3 - assert expr.eval({"a": 2}, b=2) == 4 - - @pytest.mark.parametrize("expr", GOOD_EXPRESSIONS) def test_hash(expr): assert isinstance(hash(parse_expression(expr)), int) diff --git a/tests/test_qt/test_qmenu.py b/tests/test_qt/test_qmenu.py index cc28d34c..b6865c23 100644 --- a/tests/test_qt/test_qmenu.py +++ b/tests/test_qt/test_qmenu.py @@ -61,7 +61,9 @@ def test_menu( assert redo_action.isEnabled() # useful error when we forget a required name - with pytest.raises(NameError, match="Names required to eval this expression"): + with pytest.raises( + NameError, match="Names required to eval expression 'allow_undo_redo'" + ): menu.update_from_context({}) menu._disconnect()