From cd279cfe2ec03798e51444d8365a85489430ddc2 Mon Sep 17 00:00:00 2001 From: Hashim1999164 <64767361+Hashim1999164@users.noreply.github.com> Date: Sat, 22 Aug 2026 23:26:29 +0500 Subject: [PATCH 1/2] Preserve saved globals when copying DelayEvaluator Attribute and item access copy the evaluator and previously dropped caller globals, so config_expr with custom functions failed after chaining. --- metaflow/user_configs/config_parameters.py | 8 ++--- test/unit/test_delay_evaluator.py | 42 ++++++++++++++++++++++ 2 files changed, 46 insertions(+), 4 deletions(-) create mode 100644 test/unit/test_delay_evaluator.py diff --git a/metaflow/user_configs/config_parameters.py b/metaflow/user_configs/config_parameters.py index ec38d87c161..ddf8337569b 100644 --- a/metaflow/user_configs/config_parameters.py +++ b/metaflow/user_configs/config_parameters.py @@ -301,17 +301,17 @@ def __init__(self, ex: str, saved_globals: Optional[Dict[str, Any]] = None): self._cached_expr = None def __copy__(self): - c = DelayEvaluator(self._config_expr) + # Keep caller globals by reference so config_expr("my_func()") still + # resolves after attribute/item access (which copies this object). + c = DelayEvaluator(self._config_expr, saved_globals=self._globals) c._access = self._access.copy() if self._access is not None else None - # Globals are not copied -- always kept as a reference return c def __deepcopy__(self, memo): - c = DelayEvaluator(self._config_expr) + c = DelayEvaluator(self._config_expr, saved_globals=self._globals) c._access = ( copy.deepcopy(self._access, memo) if self._access is not None else None ) - # Globals are not copied -- always kept as a reference return c def __iter__(self): diff --git a/test/unit/test_delay_evaluator.py b/test/unit/test_delay_evaluator.py new file mode 100644 index 00000000000..e1b4ebf93df --- /dev/null +++ b/test/unit/test_delay_evaluator.py @@ -0,0 +1,42 @@ +import copy + +from metaflow.user_configs.config_parameters import DelayEvaluator + + +def _sample_globals(): + def my_func(): + return "hello" + + return {"my_func": my_func} + + +def test_copy_preserves_saved_globals(): + saved = _sample_globals() + evaluator = DelayEvaluator("config", saved_globals=saved) + copied = copy.copy(evaluator) + assert copied._globals is saved + assert copied._globals["my_func"]() == "hello" + + +def test_deepcopy_preserves_saved_globals(): + saved = _sample_globals() + evaluator = DelayEvaluator("config", saved_globals=saved) + copied = copy.deepcopy(evaluator) + assert copied._globals is saved + assert copied._globals["my_func"]() == "hello" + + +def test_getattr_preserves_saved_globals(): + saved = _sample_globals() + evaluator = DelayEvaluator("config", saved_globals=saved) + chained = evaluator.project + assert chained._globals is saved + assert chained._access == ["project"] + + +def test_getitem_preserves_saved_globals(): + saved = _sample_globals() + evaluator = DelayEvaluator("config", saved_globals=saved) + chained = evaluator["project"] + assert chained._globals is saved + assert chained._access == ["project"] From f3189d8fe57f8f323ba6b3c59971cb6e46aaf192 Mon Sep 17 00:00:00 2001 From: Hashim Khan <64767361+Hashim1999164@users.noreply.github.com> Date: Sun, 6 Sep 2026 16:49:38 +0500 Subject: [PATCH 2/2] Cover chained DelayEvaluator evaluation in tests. --- metaflow/user_configs/config_parameters.py | 2 +- test/unit/test_delay_evaluator.py | 31 ++++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/metaflow/user_configs/config_parameters.py b/metaflow/user_configs/config_parameters.py index ddf8337569b..4d41ac2fdc1 100644 --- a/metaflow/user_configs/config_parameters.py +++ b/metaflow/user_configs/config_parameters.py @@ -301,7 +301,7 @@ def __init__(self, ex: str, saved_globals: Optional[Dict[str, Any]] = None): self._cached_expr = None def __copy__(self): - # Keep caller globals by reference so config_expr("my_func()") still + # Keep caller globals by reference so config_expr("my_func").project still # resolves after attribute/item access (which copies this object). c = DelayEvaluator(self._config_expr, saved_globals=self._globals) c._access = self._access.copy() if self._access is not None else None diff --git a/test/unit/test_delay_evaluator.py b/test/unit/test_delay_evaluator.py index e1b4ebf93df..0b2a9b1d894 100644 --- a/test/unit/test_delay_evaluator.py +++ b/test/unit/test_delay_evaluator.py @@ -1,5 +1,7 @@ import copy +from metaflow.flowspec import FlowStateItems +from metaflow.parameters import current_flow from metaflow.user_configs.config_parameters import DelayEvaluator @@ -10,6 +12,18 @@ def my_func(): return {"my_func": my_func} +class _DummyFlow: + _flow_state = {FlowStateItems.CONFIGS: {}} + + +def _with_dummy_flow(fn): + current_flow.flow_cls = _DummyFlow + try: + return fn() + finally: + del current_flow.flow_cls + + def test_copy_preserves_saved_globals(): saved = _sample_globals() evaluator = DelayEvaluator("config", saved_globals=saved) @@ -40,3 +54,20 @@ def test_getitem_preserves_saved_globals(): chained = evaluator["project"] assert chained._globals is saved assert chained._access == ["project"] + + +def test_chained_call_uses_saved_globals(): + class _Cfg: + project = "from-globals" + + saved = {"my_func": _Cfg} + evaluator = DelayEvaluator("my_func", saved_globals=saved) + chained = evaluator.project + assert _with_dummy_flow(chained) == "from-globals" + + +def test_copied_call_uses_saved_globals(): + saved = _sample_globals() + evaluator = DelayEvaluator("my_func()", saved_globals=saved) + copied = copy.copy(evaluator) + assert _with_dummy_flow(copied) == "hello"