Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

gh-92203: Add closure support to exec(). #92204

Merged
merged 8 commits into from
May 6, 2022

Conversation

larryhastings
Copy link
Contributor

@larryhastings larryhastings commented May 2, 2022

Add a closure keyword-only parameter to exec(). It can only be specified when exec-ing a code object that uses free variables. When specified, it must be a tuple, with exactly the number of cell variables referenced by the code object. closure has a default value of None, and it must be None if the code object doesn't refer to any free variables.

I changed the exception raised to match the exception raised by
existing code and neglected to update the corresponding test.
So... the test worked!  And now, also, it passes.
@rhettinger
Copy link
Contributor

Why do we need this? It adds complexity to a builtin with almost no payoff to real users.

@larryhastings
Copy link
Contributor Author

Quoting from the issue:

We do have a use case for this, in conjunction with PEP 649. Carl Meyer proposes to pull the code object out of a co_annotations function, and run it with a globals object that enables us to handle undefined (forward-defined) names.

@TeamSpen210
Copy link

As of commit 135cabd, the eval loop actually requires a function object, so PyEval_* functions create a temporary one anyway from the parameters passed in. That suggests there's not going to be a performance improvement or anything from using this exec() over just constructing types.FunctionType directly.

@JelleZijlstra JelleZijlstra self-requested a review May 3, 2022 03:43
}
if (!closure_is_ok) {
PyErr_Format(PyExc_TypeError,
"code object requires a closure of exactly length %d",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"requires a tuple" or "requires a tuple of closures"

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Although I imagine the textbook definition of a "closure" is different, Python uses the word "closure" to describe this object (a tuple of CellVars) in several places. For example, the __closure__ attribute of a FunctionObject, or the fc_closure field of a FrameContructor struct.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if change it to "closure should be None or a tuple of cells of exactly length %zd"?

Note also %zd instead of %d.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PyFunction_SetClosure() does not check the length of the tuple or the type of items, so perhaps it is not necessary here too, if the following code raises an exception of appropriate type.

}
else {
if (closure != NULL) {
PyErr_SetString(PyExc_ValueError,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should not it be a TypeError?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

}
else {
if (closure != NULL) {
PyErr_SetString(PyExc_ValueError,
"closure cannot be used when source is a string");
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Technically, the source can be a bytes-like object.

Would not be better to say "closure can only be used when source is a code object"?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

Copy link
Member

@JelleZijlstra JelleZijlstra left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good except for a few nits. I think it is a simple, sensible extension for exec().

} else {
int closure_is_ok =
closure
&& PyTuple_CheckExact(closure)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not allow subclasses of tuple?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I erred on the side of caution. I don't know if all the code that deals with closure objects inside CPython accept subclasses of tuple, or iterables generally; it seemed to me that the safest route was to require what CPython itself uses, which is exactly a tuple object. If this proves too restrictive we can relax the restriction later, once we prove to ourselves that it's safe to do so.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PyFunction_SetClosure() accepts subclasses of tuple.

exec(two_freevars.__code__,
two_freevars.__globals__,
closure=two_freevars.__closure__)
self.assertEquals(result, 6)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
self.assertEquals(result, 6)
self.assertEqual(result, 6)

assertEquals is deprecated

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

exec(two_freevars.__code__,
two_freevars.__globals__,
closure=my_closure)
self.assertEquals(result, 2520)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
self.assertEquals(result, 2520)
self.assertEqual(result, 2520)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

a = 2
b = 3
c = 5
def two_freevars():
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It actually has three, and the other one has four.

(Pdb) p two_freevars.__closure__
(<cell at 0x107c8b710: int object at 0x106518360>, <cell at 0x107c8b6d0: int object at 0x106518380>, <cell at 0x107c8bc50: int object at 0x106518320>)
(Pdb) p three_freevars.__closure__
(<cell at 0x107c8b710: int object at 0x106518360>, <cell at 0x107c8b6d0: int object at 0x106518380>, <cell at 0x107c8b610: int object at 0x1065183c0>, <cell at 0x107c8bc50: int object at 0x106518320>)

More importantly, it would be good to have tests passing in various bad arguments: non-tuples, tuples containing non-cells.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done:

  • Renamed three_freevars to four_freevars.
  • Then, renamed two_freevars to three_freevars.
  • Added tests passing in a correctly-sized list of cell vars instead of a tuple, and a tuple of the right length with one non-cell-var entry.

@@ -496,7 +496,7 @@ are always available. They are listed here in alphabetical order.
n += 1


.. function:: eval(expression[, globals[, locals]])
.. function:: eval(expression[, globals[, locals]], *, closure=None)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the docs for eval, but you actually changed only exec.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oops. Fixed.

I put the description of the closure argument correctly in the docs for exec, I just modified the wrong prototype. (I kept making this dumb mistake--originally the branch was called eval_closure.)

Copy link
Member

@serhiy-storchaka serhiy-storchaka left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not add this feature in eval() too?

}
if (!closure_is_ok) {
PyErr_Format(PyExc_TypeError,
"code object requires a closure of exactly length %d",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if change it to "closure should be None or a tuple of cells of exactly length %zd"?

Note also %zd instead of %d.

} else {
int closure_is_ok =
closure
&& PyTuple_CheckExact(closure)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PyFunction_SetClosure() accepts subclasses of tuple.

}
if (!closure_is_ok) {
PyErr_Format(PyExc_TypeError,
"code object requires a closure of exactly length %d",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PyFunction_SetClosure() does not check the length of the tuple or the type of items, so perhaps it is not necessary here too, if the following code raises an exception of appropriate type.

@larryhastings
Copy link
Contributor Author

GitHub isn't letting me reply to your comments individually, so I'll post one big comment here.

Why not add this feature in eval() too?

I don't know how to create a code object which represents an expression that has a free variable, except by manually assembling bytecode. We have a use case for using a closure with exec, we don't have a use case for using a closure with eval.

What if change it to "closure should be None or a tuple of cells of exactly length %zd"?

This specific exception is thrown when the code object has free variables, but the user supplied a closure that didn't match exactly. The message I'm trying to convey is "this specific code object can only be executed with a closure of length N". Making the error message more generic seems unhelpful.

I've changed the formatter to %zd.

PyFunction_SetClosure() accepts subclasses of tuple.

TARGET(COPY_FREE_VARS) in ceval.c uses PyTuple_GET_ITEM to access the cell vars in the closure object. My understanding was that the ALL_CAPS APIs for PyTuple (et al) required that exact type.

PyFunction_SetClosure() does not check the length of the tuple or the type of items, so perhaps it is not necessary here too, if the following code raises an exception of appropriate type.

PyFunction_SetClosure() is called only from C code, which we don't have to worry about being malicious. (If you're running a malicious C extension, you have worse problems than someone passing in a malformed closure object.)

But here we're permitting user code (Python code) to pass in an object used as a closure. I though it safest to ensure the object is correct before passing it in to Python's tender internals.

@larryhastings
Copy link
Contributor Author

Serhiy, do you agree that Python/bltinmodule.c line 1044 should be a TypeError?

    PyErr_SetString(PyExc_TypeError,
                    "cannot use a closure with this code object");

(And, if it looks good, can I get a signed-off review? Beta 1 is getting tagged today, and meanwhile I want to go to sleep.)

@serhiy-storchaka
Copy link
Member

I don't know how to create a code object which represents an expression that has a free variable

def makecode():
    x = 1
    return (lambda: x*x).__code__

eval(makecode())

The message I'm trying to convey is "this specific code object can only be executed with a closure of length N". Making the error message more generic seems unhelpful.

Oh, it was not clear to me that it is about "this specific code object". Maybe include a repr or the name of the code object?

My understanding was that the ALL_CAPS APIs for PyTuple (et al) required that exact type.

No, it is also used for subclasses of the specific type. *_CheckExact() is rarely used, and for reasons. But it is okay to accept only exact tuples for now.

I though it safest to ensure the object is correct before passing it in to Python's tender internals.

Agree.

@larryhastings
Copy link
Contributor Author

Well I really have to go to sleep. If any core dev gives this PR a merge approval, please somebody hit the "squash & merge" button

Copy link
Member

@serhiy-storchaka serhiy-storchaka left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM.

But I think that it may be worth to add this for eval() too. Such details can be changed after beta1.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6 participants