How to make a dict key deprecated?

Viewed 682

Issue

Let's say I have a function in python that returns a dict with some objects.

class MyObj:
    pass

def my_func():
    o = MyObj()
    return {'some string' : o, 'additional info': 'some other text'}

At some point I notice that it would make sense to rename the key 'some string' as it is misleading and not well describing what is actually stored with this key. However, if I were to just change the key, people who use this bit of code, would be really annoyed because I didn't give them time via a deprecation period to adapt their code.

Current attempt

So the way I thought about implementing a deprecation warning is using a thin wrapper around dict:

from warnings import warn

class MyDict(dict):
    def __getitem__(self, key):
        if key == 'some string':
             warn('Please use the new key: `some object` instead of `some string`')
        return super().__getitem__(key)

This way I can create the dict with the old and the new key pointing towards the same object

class MyObj:
    pass

def my_func():
    o = MyObj()
    return MyDict({'some string' : o, 'some object' : o, 'additional info': 'some other text'})

Questions:

  • What are potential ways code might break if I add this change?
  • Is there an easier (as in, less amount of changes, using existing solutions, using common patterns) way to achieve this?
2 Answers

To be honest I do not think there is something particularly wrong or an anti-pattern with your solution, except for the fact that my_func has to duplicate each deprecated key with its replacement (see below).

You could even generalize it a bit (in case you will decide to deprecate other keys):

class MyDict(dict):
    old_keys_to_new_keys = {'some string': 'some object'}
    def __getitem__(self, key):
        if key in self.old_keys_to_new_keys:
            msg = 'Please use the new key: `{}` instead of `{}`'.format(self.old_keys_to_new_keys[key], key)
            warn(msg)
        return super().__getitem__(key)

class MyObj:
    pass

def my_func():
    o = MyObj()
    return MyDict({'some string' : o, 'some object': o, 'additional info': 'some other text'})

Then

>> my_func()['some string'])
UserWarning: Please use the new key: `some object` instead of `some string`

All you have to do now in order to "deprecate" more keys is update old_keys_to_new_keys.

However,

note how my_func has to duplicate each deprecated key with its replacement. This violates the DRY principle and will clutter the code if and when you do need to deprecate more keys (and you will have to remember to update both MyDict.old_keys_to_new_keys and my_func). If I may quote Raymond Hettinger:

There must be a better way

This can be remedied with the following changes to __getitem__:

def __getitem__(self, old_key):
    if old_key in self.old_keys_to_new_keys:
        new_key = self.old_keys_to_new_keys[old_key]
        msg = 'Please use the new key: `{}` instead of `{}`'.format(new_key, old_key)
        warn(msg)
        self[old_key] = self[new_key]  # be warned - this will cause infinite recursion if
                                       # old_key == new_key but that should not really happen
                                       # (unless you mess up old_keys_to_new_keys)
    return super().__getitem__(old_key)

Then my_func can only use the new keys:

def my_func():
    o = MyObj()
    return MyDict({'some object': o, 'additional info': 'some other text'})

The behavior is the same, any code using the deprecated keys will get the warning (and, of course, accessing the new keys work):

print(my_func()['some string'])
# UserWarning: Please use the new key: `some object` instead of `some string`
# <__main__.MyObj object at 0x000002FBFF4D73C8>
print(my_func()['some object'])
# <__main__.MyObj object at 0x000002C36FCA2F28>

As others have said, your current approach seems quite good already. The only potential caveat I see is that the MyDict class centralizes all the knowledge about deprecated values. Depending on your use case, you might prefer to define what is and what is not deprecated at the point where it is defined instead. You could for example do something along these lines:

from warnings import warn

class MyDict(dict):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self._deprecated_keys = {}
    def __getitem__(self, key):
        if key in self._deprecated_keys:
            new_key = self._deprecated_keys[key]
            if new_key:
                warn(f'Please use the new key: `{new_key}` instead of `{key}`.')
            else:
                warn(f'Deprecated key: `{key}`.')
        return super().__getitem__(key)
    # Option A
    def put_deprecated(self, key, value, new_key=None):
        self._deprecated_keys[key] = new_key
        self[key] = value
    # Option B
    def put(self, key, value, deprecated_keys=None):
        self[key] = value
        for deprecated_key in (deprecated_keys or []):
            self[deprecated_key] = value
            self._deprecated_keys[deprecated_key] = key


my_dict = MyDict()
# Option A
my_dict['new_key'] = 'value'
my_dict.put_deprecated('old_key', 'value', new_key='new_key')
# Option B
my_dict.put('new_key', 'value', deprecated_keys=['old_key'])

my_dict['old_key']
# UserWarning: Please use the new key: `new_key` instead of `old_key`.

Option A requires repetition but allows for deprecated keys without replacement, while option B is more succinct. The advantage here is that defining new keys and deprecating old ones is done at the point where the key and value are assigned, instead of requiring changing MyDict.

Related