4

I've inherited an API to maintain.

Users can pass a callback function to the class which gets called on some event. The callback function is currently passed in a single argument.

I need to change the expected signature of the callback function to accept additional arguments.

This would be fine if everyones' callback function took in *args and **kwargs, but unfortunately they don't and it's too much work to get everyone to change them.

What is the cleanest way for me to make the change in the API while keeping the callback backwards compatible?

I have this as a shoe-in but it's not very future proof, nor is it very elegant:

...
if self._callback.func_code.co_argcount > 1:
    self._callback( data, additional_arg )
else:
    self._callback( data )
...
  • Perhaps you can put in a deprecation notice to encourage people to change eventually? – Steven Burnap Dec 3 '14 at 19:38
  • You could also make users using the new functionality use a new function to register the callback, and record then the way it needs to be called. Probably makes more sense for objects that already have a pool of callbacks though. – StarWeaver Dec 4 '14 at 1:58
5

In Python, it's "Easier to ask for forgiveness than permission" - it is common "Pythonic" practice to use exceptions and error handling, rather than e.g. if checking up-front ("Look before you leap") to handle potential problems. The documentation provides a few examples that demonstrate where the latter can really cause problems - if the situation changes between the look and the leap, you have serious trouble!

On that basis, and given that a function will raise a TypeError if provided with the wrong number of arguments, you could use:

try:
    # Have a go with the new interface
    self._callback(data, additional_arg)
except TypeError:
    # Fall back to the old one
    self._callback(data)

You could use a decorator function to wrap any callback:

def api_compatible(func):
    @functools.wraps(func)
    def wrapper(data, *args, **kwargs):
        try:
            return func(data, *args, **kwargs)
        except TypeError:
            return func(data)
    return wrapper

Now it becomes:

self._callback = api_compatible(callback)
...
self._callback(data, additional_arg)
  • Philosophically, this is a good approach. It will take a bit longer to handle exceptions than to pre-check; probably not a concern unless it's in a tight loop. It also suffers slightly from exception conflation, in that it doesn't distinguish between TypeErrors that are really "ArgumentTypeErrors" and TypeErrors raised within the callback itself. Finally, the callback here is extremely unlikely to change between the look and the leap, so forgiveness is less crucial. Still, I give it a high Zen of Python score. – Jonathan Eunice Dec 4 '14 at 14:55
  • 1
    @JonathanEunice excellent points, I agree with them all. Particularly "exception conflation" (great phrase, by the way); unfortunately the standard suite of exceptions makes it difficult to tell the difference between a problem with the number of arguments and a problem with the type of the arguments. I suppose the key issue is that the subsequent call to func(data) may succeed because the problematic argument isn't included, rather than because the function actually needed the old API, which conflicts with Zen point #10. – jonrsharpe Dec 4 '14 at 15:00
4

An explicit check of the callback's ability to handle parameters is about the best you're going to be able to do. Python may be loosie-goosie in its duck typing, but it will complain and raise a TypeError exception if you feed a function the wrong number of parameters. No ifs, ands, or buts about that.

You have existing functions in the field that you don't feel you can change--and probably for good reason. So having an inspection that asks, "what can this callback function accept?" or "what does this callback function expect?"--it may be inelegant, but that's what's available to you.

The other choices involve:

  1. Having some form of object-relative state (such as an additional instance value or method) or even global state (whether a true global variable, a class variable, a singleton reporting object, or whathaveyou) which callbacks can reference to get the extended information you're now offering them. This is how C, Unix, and many other codebases handle exceptional and additional information. It is, unfortunately, not especially elegant--and it can be quite problematic/unworkable for multithreaded apps.

  2. On the off chance that your data parameter is an extensible type, you might be able to add fields to it. If it's a dict or similar structure you're golden, as long as the callbacks play by duck typing rules and don't strictly check that they got only the fields back they expected. This is a trick that often works in dynamic languages, even though it would fall flat on its face in statically typed languages/data passing environments. But, you have to get lucky to have this work. If data is a more static type, you're back to choice 1 or your inspection-based approach ("choice 0").

Update

As an aside, your code will run fine in Python 2. But Python 3 changes the place the code is stored. To encompass both your current Python 2 and future moves to Python 3, here's a shim that works in both:

import sys
_PY2 = sys.version_info[0] == 2

def arg_count(f):
    code = f.func_code if _PY2 else f.__code__
    return code.co_argcount

Then:

if arg_count(self._callback) > 1:
    ....

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.