More similarity between exec() and eval()

I propose that the only differences between these two builtins should be:

  1. If the first argument is a string, then it is compiled as a file input for exec() or an expression for eval().
  2. exec() returns None, and eval() returns the evaluation of the source code/string.

This means that eval() will accept a closure keyword. This is useful because eval() could be used to modify a nonlocal free variable if:

  • an expression string contains a walrus operator.
  • an expression string is compiled with mode=‘eval’ and contains a walrus operator.
  • a file input is compiled with mode=‘exec’ and modifies nonlocal variables. This is the same as calling exec() with the same arguments.

Changes to the documentation:

  1. exec() only requires the optional globals to be a dictionary or subclass. This is an error in the doc. For example:
>>> class D(dict): pass
...
>>> exec('print(x)', D(x = 3))
3
  1. Add ‘closure’ argument to eval().
  2. Name of the first parameter which indicates that it can be an expression for eval() or a file input for exec() or a code object for either function.
    Suggestion: exec(source_or_code, …) and eval(expr_or_code, …)
  3. When a code object is passed, the result of executing it is returned. If it was compiled with mode=‘eval’, this is the evaluation value. If it was compiled with mode=‘exec’, the result is None.
  4. Any assignments or deletions performed while executing the code (whether supplied as an argument or compiled from a string) are performed in the dictionary given as the locals argument.
  5. Other than the actual differences proposed above, the documentation for both functions should be identical.

Implementation suggestion:

I would replace the builtin_exec[_impl] and builtin_eval[_impl] functions with a single builtin_exec_eval_impl function which has an additional boolean is_exec argument. The only effect of this flag is when a string is passed as the first argument.

  1. For exec(), Py_file_input is passed to PyRun_String() or PyRun_StringFlags(), and None is returned.
  2. For eval(), Py_eval_input is passed to PyRun_String() or PyRun_StringFlags(), and the result is returned.

I really like the idea of moving towards a unification and simplification these two functions. A couple ideas:

dictionary or subclass . This is an error in the doc.

I don’t think this is an error. Subclasses are dictionaries. In OO terminology, we say a subclass object is a superclass object. The documentation should only specify if subclasses weren’t allowed.

I like this, and it’s transparent since they’re positional-only arguments.

Does that change behaviour? That might be hard to sell.

I’ve always liked simplifications to documentation. It might be easiest for the reader if exec says something like “This function calls eval but…” This makes it easy for the reader to understand exactly how exec and eval differ.

1 Like

Hi Neil,

The doc for eval() states " globals must be a dictionary." This is the actual behavior.
The doc for exec() states “it must be a dictionary (and not a subclass of dictionary)” This is the error I alluded to. The actual behavior is to accept any dictionary (same as eval()).

Does that change behaviour? That might be hard to sell.

No.
As far as I can tell by reading the C code, this is the actual current behavior. The locals argument is given to PyEval_Eval[Ex] or PyRun_String[Flags]. The latter eventually calls the former with the same locals.
Here’s an example:

>>> s = '''
... print(locals())
... x = 4
... del y
... print(locals())
... '''
>>> d = D(y = 3)
>>> d
{'y': 3}
>>> exec(s, globals(), d)
{'y': 3}
{'x': 4}
>>> d
{'x': 4}

This shows that the exec() adds x and removes y from d.

More importantly, it does not appear to bypass __setitem__ (which might happen if it were assuming an actual dict).

...     def __setitem__(self, key, val):
...             print("Set %r to %r" % (key, val))
...             super().__setitem__(key, val)
... 
>>> d = Logger()
>>> eval("(x := 4)", d)
Set 'x' to 4
4
>>> exec("y = 5", d)
Set 'y' to 5

So current behaviour for both functions does seem to permit subclasses. Question, though: is this mandated or merely an implementation detail?

1 Like

Sorry, but I disagree with your interpretation. Subclasses of dictionary are dictionaries. isinstance(D(), dict) is always true.

As for the closure parameter, see the original PR: https://github.com/python/cpython/pull/92204. The author did not have a use case for it.

That’s not a matter of interpretation. It’s clearly stated in the docs. The docstring is less clear.

“”“If only globals is provided, it must be a dictionary (and not a subclass of dictionary), which will be used for both the global and the local variables.”“”

help(exec)
“”“The globals must be a dictionary and locals can be any mapping…”“”

But the current CPython implementation is quite happy to use a subclass, which makes the docs page wrong. It is not, however, willing to use an arbitrary object. IMO this is a docs bug and it should be brought in line with the docstring (by removing the “and not a subclass” parenthesis).

(I’m not sure why globals has to be a dict but locals can be any mapping. Either way, it uses getitem/setitem correctly.)

Oh, sorry, I didn’t notice that! I thought he was interpreting that parenthetical comment!

1 Like

If the code declares a variable as global, assignments and deletions operate on globals. If a variable is a closure, assignments and deletions operate on the cell object. Otherwise assignments and deletions operate on locals. If the locals argument is omitted, then globals and locals are the same dict.

Also, for both exec and eval, the first assignment is to add __builtins__ to globals if it doesn’t already exist. The value of __builtins__ in globals is set as the __builtins__ attribute of any function that’s instantiated with the given globals. When the function is called, __builtins__ is set as the f_builtins attribute of the executing frame.