Learning Python - Mark Lutz [557]
To simplify our work, we can also make the assumption that a call is valid in general—i.e., that all arguments either will receive values (by name or position), or will be omitted intentionally to pick up defaults. This assumption won’t necessarily hold, because the function has not yet actually been called when the wrapper logic tests validity—the call may still fail later when invoked by the wrapper layer, due to incorrect argument passing. As long as that doesn’t cause the wrapper to fail any more badly, though, we can finesse the validity of the call. This helps, because validating calls before they are actually made would require us to emulate Python’s argument-matching algorithm in full—again, too complex a procedure for our tool.
Matching algorithm
Now, given these constraints and assumptions, we can allow for both keywords and omitted default arguments in the call with this algorithm. When a call is intercepted, we can make the following assumptions:
All N passed positional arguments in *pargs must match the first N expected arguments obtained from the function’s code object. This is true per Python’s call ordering rules, outlined earlier, since all positionals precede all keywords.
To obtain the names of arguments actually passed by position, we can slice the list of all expected arguments up to the length N of the *pargs positionals tuple.
Any arguments after the first N expected arguments either were passed by keyword or were defaulted by omission at the call.
For each argument name to be validated, if it is in **kargs it was passed by name, and if it is in the first N expected arguments it was passed by position (in which case its relative position in the expected list gives its relative position in *pargs); otherwise, we can assume it was omitted in the call and defaulted and need not be checked.
In other words, we can skip tests for arguments that were omitted in a call by assuming that the first N actually passed positional arguments in *pargs must match the first N argument names in the list of all expected arguments, and that any others must either have been passed by keyword and thus be in **kargs, or have been defaulted. Under this scheme, the decorator will simply skip any argument to be checked that was omitted between the rightmost positional argument and the leftmost keyword argument, between keyword arguments, or after the rightmost positional in general. Trace through the decorator and its test script to see how this is realized in code.
Open Issues
Although our range-testing tool works as planned, two caveats remain. First, as mentioned earlier, calls to the original function that are not valid still fail in our final decorator. The following both trigger exceptions, for example:
omitargs()
omitargs(d=8, c=7, b=6)
These only fail, though, where we try to invoke the original function, at the end of the wrapper. While we could try to imitate Python’s argument matching to avoid this, there’s not much reason to do so—since the call would fail at this point anyhow, we might as well let Python’s own argument-matching logic detect the problem for us.
Lastly, although our final version handles positional arguments, keyword arguments, and omitted defaults, it still doesn’t do anything explicit about *args and **args that may be used in a decorated function that accepts arbitrarily many arguments. We probably don’t need to care for our purposes, though:
If an extra keyword argument is passed, its name will show up in **kargs and can be tested normally if mentioned to the decorator.
If an extra keyword argument is not passed, its name won’t be in either **kargs or the sliced expected positionals list, and it will thus not be checked—it is treated as though it were defaulted, even though it is really an optional extra argument.
If an extra positional argument is passed, there’s no way to reference it in the decorator anyhow—its name won’t be in either **kargs or the sliced expected arguments list, so it will simply be skipped. Because