diff --git a/doc/maintenance.md b/doc/maintenance.md index ba72a88106a..dabf808d76e 100644 --- a/doc/maintenance.md +++ b/doc/maintenance.md @@ -146,6 +146,47 @@ supported version of Python. Minimum Python and NumPy version support should be adjusted upward on every major and minor release, but never on a patch release. +## Backwards compatibility and deprecation policy + +PyGMT is still undergoing rapid developement. All of the API is subject to change +until the v1.0.0 release. + +Basic policy for backwards compatibility: + +- Any incompatible changes should go through the deprecation process below. +- Incompatible changes are only allowed in major and minor releases, not in + patch releases. +- Incompatible changes should be documented in the release notes. + +When making incompatible changes, we should follow the process: + +- Discuss whether the incompatible changes are necessary on GitHub. +- Make the changes in a backwards compatible way, and raise a `FutureWarning` + warning for old usage. At least one test using the old usage should be added. +- The warning message should clearly explain the changes and include the versions + in which the old usage is deprecated and is expected to be removed. +- The `FutureWarning` warning should appear for 2-4 minor versions, depending on + the impact of the changes. It means the deprecation period usually lasts + 3-12 months. +- Remove the old usage and warning when reaching the declared version. + +To rename a function parameter, add the `@deprecated_parameter` decorator +before the function definition (but after the `@use_alias` decorator if it exists). +Here is an example: + +``` +@fmt_docstring +@use_alias(J="projection", R="region", V="verbose") +@kwargs_to_strings(R="sequence") +@deprecate_parameter("sizes", "size", "v0.4.0", remove_version="v0.6.0") +def plot(self, x=None, y=None, data=None, size=None, direction=None, **kwargs): + pass +``` + +In this case, the old parameter name `sizes` is deprecated since v0.4.0, and will be +fully removed in v0.6.0. The new parameter name is `size`. + + ## Making a Release We try to automate the release process as much as possible. diff --git a/pygmt/helpers/__init__.py b/pygmt/helpers/__init__.py index 84aaad0fb9a..2b372c59aa1 100644 --- a/pygmt/helpers/__init__.py +++ b/pygmt/helpers/__init__.py @@ -1,7 +1,12 @@ """ Functions, classes, decorators, and context managers to help wrap GMT modules. """ -from pygmt.helpers.decorators import fmt_docstring, kwargs_to_strings, use_alias +from pygmt.helpers.decorators import ( + deprecate_parameter, + fmt_docstring, + kwargs_to_strings, + use_alias, +) from pygmt.helpers.tempfile import GMTTempFile, unique_name from pygmt.helpers.utils import ( args_in_kwargs, diff --git a/pygmt/helpers/decorators.py b/pygmt/helpers/decorators.py index dcd26723142..485e05258de 100644 --- a/pygmt/helpers/decorators.py +++ b/pygmt/helpers/decorators.py @@ -7,6 +7,7 @@ """ import functools import textwrap +import warnings import numpy as np from pygmt.exceptions import GMTInvalidInput @@ -439,3 +440,82 @@ def new_module(*args, **kwargs): return new_module return converter + + +def deprecate_parameter(oldname, newname, deprecate_version, remove_version): + """ + Decorator to deprecate a parameter. + + The old parameter name will be automatically swapped to the new parameter + name, and users will receive a FutureWarning to inform them of the pending + deprecation. + + Use this decorator below the ``use_alias`` decorator. + + Parameters + ---------- + oldname : str + The old, deprecated parameter name. + newname : str + The new parameter name. + deprecate_version : str + The PyGMT version when the old parameter starts to be deprecated. + remove_version : str + The PyGMT version when the old parameter will be fully removed. + + Examples + -------- + >>> @deprecate_parameter("sizes", "size", "v0.0.0", "v9.9.9") + ... @deprecate_parameter("colors", "color", "v0.0.0", "v9.9.9") + ... @deprecate_parameter("infile", "data", "v0.0.0", "v9.9.9") + ... def module(data, size=0, **kwargs): + ... "A module that prints the arguments it received" + ... print(f"data={data}, size={size}, color={kwargs['color']}") + >>> # new names are supported + >>> module(data="table.txt", size=5.0, color="red") + data=table.txt, size=5.0, color=red + >>> # old names are supported, FutureWarning warnings are reported + >>> with warnings.catch_warnings(record=True) as w: + ... module(infile="table.txt", sizes=5.0, colors="red") + ... # check the number of warnings + ... assert len(w) == 3 + ... for i in range(len(w)): + ... assert issubclass(w[i].category, FutureWarning) + ... assert "deprecated" in str(w[i].message) + ... + data=table.txt, size=5.0, color=red + >>> # using both old and new names will raise an GMTInvalidInput exception + >>> import pytest + >>> with pytest.raises(GMTInvalidInput): + ... module(data="table.txt", size=5.0, sizes=4.0) + ... + """ + + def deprecator(module_func): + """ + The decorator that creates the new function to work with both old and + new parameters. + """ + + @functools.wraps(module_func) + def new_module(*args, **kwargs): + """ + New module instance that converts old parameters to new parameters. + """ + if oldname in kwargs: + if newname in kwargs: + raise GMTInvalidInput( + f"Can't provide both '{newname}' and '{oldname}'." + ) + msg = ( + f"The '{oldname}' parameter has been deprecated since {deprecate_version}" + f" and will be removed in {remove_version}." + f" Please use '{newname}' instead." + ) + warnings.warn(msg, category=FutureWarning, stacklevel=2) + kwargs[newname] = kwargs.pop(oldname) + return module_func(*args, **kwargs) + + return new_module + + return deprecator