#3345·metaflow

DelayEvaluator.__copy__/__deepcopy__ drop saved_globals, breaking config_expr() with custom functions

Author: SahilKumar75Created Aug 22, 2026Updated Sep 10, 2026

What happened?

config_expr() is documented to support calling the user's own module level functions inside the expression, for example config_expr("my_func()"). It captures the caller's globals for this purpose:

parent_globals = inspect.currentframe().f_back.f_globals
...
saved_globals=parent_globals

DelayEvaluator.copy and deepcopy both construct a fresh DelayEvaluator using only the expression string, without forwarding the saved globals:

def __copy__(self):
    c = DelayEvaluator(self._config_expr)
    c._access = self._access.copy() if self._access is not None else None
    # Globals are not copied -- always kept as a reference
    return c

Since saved_globals defaults to None in init, the copy's _globals becomes None. getattr and getitem both call self.copy() on every attribute or item access, which is the common way DelayEvaluator is actually used, for example config_expr("config").project.name. So the moment any attribute or item is chained onto a DelayEvaluator built with config_expr(), the captured caller globals are lost.

What did you expect to happen?

A custom function referenced in a config_expr() expression should still resolve correctly after any attribute or item access on the returned DelayEvaluator, since the comment right above the affected line says globals are "always kept as a reference."

Reproduction

# in a flow's module
def my_func():
    return "hello"

class MyFlow(FlowSpec):
    config = Config("config")

    @environment(vars={"foo": config_expr("my_func()")})
    @step
    def start(self):
        ...

This works. But:

@project(name=config_expr("my_func()").upper())

raises NameError: name 'my_func' is not defined, because .upper() triggers getattr, which calls copy(), which drops _globals.

Proposed fix

Pass self._globals through in both copy and deepcopy:

def __copy__(self):
    c = DelayEvaluator(self._config_expr, saved_globals=self._globals)
    ...

def __deepcopy__(self, memo):
    c = DelayEvaluator(self._config_expr, saved_globals=self._globals)
    ...

I have this fix ready locally along with a couple of unit tests reproducing the issue before the fix and passing after. Happy to open a PR once this is acknowledged.