Use warnings to evolve your code
Regardless of the versioning system you're using, once you reach your first stable version, the commitment to your end users must be that you give them time to adapt to the changes in your program. So whenever you want to introduce a breaking change release it under a new interface, and in parallel, start emitting DeprecationWarning
or UserWarning
messages whenever someone invokes the old one. Maintain this state for a defined period (for example six months), and communicate explicitly in the warning message the timeline for when users have to migrate.
This gives everyone time to move to the new interface without breaking their system, and then the library may remove the change and get rid of the old design chains forever. As an added benefit, only people using the old interface will ever see the warning, as opposed to affecting everyone (as seen with the semantic versioning major version bump).
If you're following semantic versioning you'd do this change in a minor
release, and you'll finally remove the functionality in another minor
release. As you've given your users enough time to adequate to the new version of the code, it's not understood as a breaking change.
This allows too for your users to be less afraid and stop upper-pinning you in their dependencies.
Another benefit of using warnings is that if you configure your test runner to capture the warnings (which you should!) you can use your test suite to see the real impact of the deprecation, you may even realize why was that feature there and that you can't deprecate it at all.
Using warnings⚑
Even though there are many warnings, I usually use UserWarning
or DeprecationWarning
. The full list is:
Class | Description |
---|---|
Warning | This is the base class of all warning category classes. |
UserWarning | The default category for warn(). |
DeprecationWarning | Warn other developers about deprecated features. |
FutureWarning | Warn other end users of applications about deprecated features. |
SyntaxWarning | Warn about dubious syntactic features. |
RuntimeWarning | Warn about dubious runtime features. |
PendingDeprecationWarning | Warn about features that will be deprecated in the future (ignored by default). |
ImportWarning | Warn triggered during the process of importing a module (ignored by default). |
UnicodeWarning | Warn related to Unicode. |
BytesWarning | Warn related to bytes and bytearray. |
ResourceWarning | Warn related to resource usage (ignored by default). |
How to raise a warning⚑
Warning messages are typically issued in situations where it is useful to alert the user of some condition in a program, where that condition doesn’t warrant raising an exception and terminating the program.
import warnings
def f():
warnings.warn('Message', DeprecationWarning)
Suppressing a warning⚑
To disable in the whole file, add to the top:
import warnings
warnings.filterwarnings("ignore", message="divide by zero encountered in divide")
If you want this to apply to only one section of code, then use the warnings context manager:
import warnings
with warnings.catch_warnings():
warnings.filterwarnings("ignore", message="divide by zero encountered in divide")
# .. your divide-by-zero code ..
And if you want to disable it for the whole code base configure pytest accordingly.
How to evolve your code⚑
To ensure that the transition is smooth you need to tweak your code so that the user can switch a flag and make sure that their code keeps on working with the new changes. For example imagine that we have a class MyClass
with a method my_method
.
class MyClass:
def my_method(self, argument):
# my_method code goes here
You can add an argument deprecate_my_method
that defaults to False
, or you can take the chance to change the signature of the function, so that if the user is using the old argument, it uses the old behaviour and gets the warning, and if it's using the new argument, it uses the new. The advantage of changing the signature is that you don't need to do another deprecation for the temporal argument flag.
!!! note "Or you can use environmental variables"
class MyClass:
def __init__(self, deprecate_my_method = False):
self.deprecate_my_method = deprecate_my_method
def my_method(self, argument):
if self.deprecate_my_method:
# my_method new functionality
else:
warnings.warn("Use my_new_method instead", UserWarning)
# my_method old code goes here
That way when users get the new version of your code, if they are not using my_method
they won't get the exception, and if they are, they can change how they initialize their classes with MyClass(deprecate_my_method=True)
, run their tests tweaking their code to meet the new functionality and make sure that they are ready for the method to be deprecated. Once removed, another UserWarning will be raised to stop using deprecate_my_method
as an argument to initialize the class as it is no longer needed.
Until you remove the old code, you need to keep both functionalities and make sure all your test suite works with both cases. To do that, create the warning, run the tests and see what tests are raising the exception. For each of them you need to think if this test will make sense with the new code:
- If it doesn't, make sure that the warning is raised.
- If it is, make sure that the warning is raised and create another test with the
deprecate_my_method
enabled.
Once the deprecation date arrives you'll need to search for the date in your code to see where the warning is raised and used, remove the old functionality and update the tests. If you used a temporal argument to let the users try the new behaviour, issue the warning to deprecate it.
Use environmental variables⚑
A cleaner way to handle it is with environmental variables, that way you don't need to change the signature of the function twice. I've learned this from boto where they informed their users this way:
- If you wish to test the new feature we have created a new environment variable
BOTO_DISABLE_COMMONNAME
. Setting this totrue
will suppress the warning and use the new functionality. - If you are concerned about this change causing disruptions, you can pin your version of
botocore
to<1.28.0
until you are ready to migrate. -
If you are only concerned about silencing the warning in your logs, use
warnings.filterwarnings
when instantiating a new service client.import warnings warnings.filterwarnings('ignore', category=FutureWarning, module='botocore.client')
Testing warnings⚑
To test the function with pytest you can use pytest.warns
:
import warnings
import pytest
def test_warning():
with pytest.warns(UserWarning, match='my warning'):
warnings.warn("my warning", UserWarning)
For the DeprecationWarnings
you can use deprecated_call
:
Or you can use deprecated
:
def test_myfunction_deprecated():
with pytest.deprecated_call():
f()
@deprecated(version='1.2.0', reason="You should use another function")
def some_old_function(x, y):
return x + y
But it adds a dependency to your program, although they don't have any downstream dependencies.