diff --git a/.gitignore b/.gitignore index c9d19f557d..3faf8239d5 100644 --- a/.gitignore +++ b/.gitignore @@ -39,8 +39,8 @@ pvlib/spa_c_files/spa.h pvlib/spa_c_files/spa_tester.c # generated documentation +docs/sphinx/source/contributing/generated docs/sphinx/source/reference/generated -docs/sphinx/source/reference/*/generated docs/sphinx/source/savefig docs/sphinx/source/gallery docs/sphinx/source/sg_execution_times.rst diff --git a/docs/sphinx/source/contributing/devops.rst b/docs/sphinx/source/contributing/devops.rst new file mode 100644 index 0000000000..ee00606119 --- /dev/null +++ b/docs/sphinx/source/contributing/devops.rst @@ -0,0 +1,73 @@ +.. _devops: + +Development Operations +====================== + +This page provides information on specific development needs found in the pvlib-python ecosystem. Some specific Python concepts may be used in this section. + +Deprecations +------------ +Let's start by what is a deprecation: sometimes, a feature in the library is no longer needed because it has been superceded by better altenatives, or because better practices are considred beneficial. In this case, just doing that change (a removal, a rename) will probably be a **breaking change** for a number of users. Both developers and users desire to not get their code broken after a release in normal circumstances. There are a number of approaches to make these changes gradually, so at least there is some time in-between to warn about upcoming changes and to allow users to adapt their code. + +There are two ways to warn about upcoming changes: + +- Passively, by expecting users to read whatsnew entries, new version announcements on the mailing list, or by keeping a close eye on the repo activity. +- Actively, via raising warnings with specific instructions when any of these deprecated features are used. + +While the pros for the latter are almost obvious, there is a main weakness; it imposes a number of extra steps to take and more code to maintain by the developers. This guide strives to close that gap. + +pvlib's submodule :py:mod:`pvlib._deprecation` has some utilities to ease the implementation of deprecations. + +Deprecation Warnings and Messages +--------------------------------- +This is about the ``Exception`` that gets raised, but quickly dismished by the interpreter after logging. They automatically leave a text trace in the output buffer (console) so it can be seen by the user. In code terms, the following line raises a warning: + +.. code-block:: + + import warnings + + warnings.warn("This feature is deprecated!") + +As a general rule, try to be concise on what the problem is and how to fix that. By default, Python will automatically inform about where the issue was found (although that can be modified, again in code terms, by setting a custom ``stacklevel`` in the warning factory). + +List of pvlib deprecation helpers +--------------------------------- + +.. py:module:: pvlib._deprecation + +.. currentmodule:: pvlib + +.. autosummary:: + :toctree: generated/ + + _deprecation.deprecated + _deprecation.renamed_kwarg_warning + _deprecation.renamed_key_items_warning + +Know your deprecation helper +---------------------------- +Remember to import the submodule. + +.. code-block:: + + from pvlib import _deprecation + +.. contents:: Table of Contents + :local: + +Deprecate a function +~~~~~~~~~~~~~~~~~~~~ +See :py:func:`pvlib._deprecation.deprecated`. + +Rename keyword parameters +~~~~~~~~~~~~~~~~~~~~~~~~~ +Applies both to *positional-or-keyword* parameters and *keyword-only* parameters. +You can check out the differences at the `Python docs glossary `_. + +See :py:func:`pvlib._deprecation.renamed_kwarg_warning`. + +Rename an item from a collection +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +For example, the key an item uses in a dictionary, the column in a ``pandas.DataFrame`` or any key-indexed object. Intended for objects returned by pvlib functions in the public API. + +See :py:func:`pvlib._deprecation.renamed_key_items_warning` diff --git a/docs/sphinx/source/contributing/index.rst b/docs/sphinx/source/contributing/index.rst index e02098ea79..df8a64986c 100644 --- a/docs/sphinx/source/contributing/index.rst +++ b/docs/sphinx/source/contributing/index.rst @@ -12,3 +12,4 @@ Contributing how_to_contribute_new_code style_guide testing + devops diff --git a/pvlib/_deprecation.py b/pvlib/_deprecation.py index aedb4d5096..49d26f3edf 100644 --- a/pvlib/_deprecation.py +++ b/pvlib/_deprecation.py @@ -169,14 +169,15 @@ def warn_deprecated( obj_type='attribute', addendum='', removal=''): """ Used to display deprecation in a standard way. + Parameters ---------- since : str The release at which this API became deprecated. message : str, optional Override the default deprecation message. The format - specifier `%(name)s` may be used for the name of the function, - and `%(alternative)s` may be used in the deprecation message + specifier ``%(name)s`` may be used for the name of the function, + and ``%(alternative)s`` may be used in the deprecation message to insert the name of an alternative to the deprecated function. `%(obj_type)s` may be used to insert a friendly name for the type of object being deprecated. @@ -198,12 +199,14 @@ def warn_deprecated( The object type being deprecated. addendum : str, optional Additional text appended directly to the final message. + Examples -------- - Basic example:: - # To warn of the deprecation of "matplotlib.name_of_module" - warn_deprecated('1.4.0', name='matplotlib.name_of_module', - obj_type='module') + Basic example: + + >>> # To warn of the deprecation of "pvlib.name_of_module" + >>> warn_deprecated('1.4.0', name='pvlib.name_of_module', + >>> obj_type='module') """ message = '\n' + _generate_deprecation_message( since, message, name, alternative, pending, obj_type, addendum, @@ -217,6 +220,7 @@ def deprecated(since, message='', name='', alternative='', pending=False, addendum='', removal=''): """ Decorator to mark a function or a class as deprecated. + Parameters ---------- since : str @@ -224,8 +228,8 @@ def deprecated(since, message='', name='', alternative='', pending=False, required. message : str, optional Override the default deprecation message. The format - specifier `%(name)s` may be used for the name of the object, - and `%(alternative)s` may be used in the deprecation message + specifier ``%(name)s`` may be used for the name of the object, + and ``%(alternative)s`` may be used in the deprecation message to insert the name of an alternative to the deprecated object. name : str, optional @@ -234,9 +238,11 @@ def deprecated(since, message='', name='', alternative='', pending=False, though this is useful in the case of renamed functions, where the new function is just assigned to the name of the deprecated function. For example:: + def new_function(): ... oldFunction = new_function + alternative : str, optional An alternative API that the user may use in place of the deprecated API. The deprecation warning will tell the user about this alternative @@ -251,12 +257,14 @@ def new_function(): with *pending*. addendum : str, optional Additional text appended directly to the final message. + Examples -------- - Basic example:: - @deprecated('1.4.0') - def the_function_to_deprecate(): - pass + Basic example: + + >>> @deprecated('1.4.0') + >>> def the_function_to_deprecate(): + >>> pass """ def deprecate(obj, message=message, name=name, alternative=alternative, @@ -335,7 +343,7 @@ def renamed_kwarg_warning(since, old_param_name, new_param_name, removal=""): .. note:: Documentation for the function may updated to reflect the new parameter - name; it is suggested to add a |.. versionchanged::| directive. + name; it is suggested to add a ``.. versionchanged::`` directive. Parameters ---------- @@ -389,3 +397,193 @@ def wrapper(*args, **kwargs): return wrapper return deprecate + + +def renamed_key_items_warning(since, old_to_new_keys_map, removal=""): + """ + Decorator to mark a possible key item (e.g. ``df["key"]``) of an object as + deprecated and replaced with other attribute. + + Raises a warning when the deprecated attribute is used, and uses the new + attribute instead, by wrapping the ``__getattr__`` method of the object. + See [1]_. + + While this implementation is decorator-like, Python syntax won't allow + ``@decorator`` for applying it. Two sets of parenthesis are required: + the first one configures the wrapper and the second one applies it. + This leaves room for reusability too. + + Code is inspired by [2]_, thou it has been generalized to arbitrary data + types. + + .. warning:: + Ensure ``removal`` date with a ``fail_on_pvlib_version`` decorator in + the test suite. + + .. note:: + This works for any object that implements a ``__getitem__`` method, + such as dictionaries, DataFrames, and other collections. + + Parameters + ---------- + since : str + The release at which this API became deprecated. + old_to_new_keys_map : dict + A dictionary mapping old keys to new keys. + removal : str, optional + The expected removal version, in order to compose the Warning message. + + Returns + ------- + object + A new object that behaves like the original, but raises a warning + when accessing deprecated keys and returns the value of the new key. + + Examples + -------- + >>> dict_obj = {"new_key": "renamed_value", "another_key": "another_value"} + >>> dict_obj = renamed_key_items_warning( + ... "1.4.0", {"old_key": "new_key"} + ... )(dict_obj) + >>> dict_obj["old_key"] + pvlibDeprecationWarning: Please use 'new_key' instead of 'old_key'. \ + Deprecated since 1.4.0 and will be removed soon. + 'renamed_value' + >>> isinstance(d, dict) + True + >>> type(dict_obj) + + + >>> dict_obj = {"new_key": "renamed_value", "new_key2": "another_value"} + >>> dict_obj = renamed_key_items_warning( + ... "1.4.0", {"old_key": "new_key", "old_key2": "new_key2"}, "1.6.0" + ... )(dict_obj) + >>> dict_obj["old_key2"] + pvlibDeprecationWarning: Please use 'new_key2' instead of 'old_key2'. \ + Deprecated since 1.4.0 and will be removed in 1.6.0. + 'another_value' + + You can even chain the decorator to rename multiple keys at once: + + >>> dict_obj = {"new_key1": "value1", "new_key2": "value2"} + >>> dict_obj = renamed_key_items_warning( + ... "0.1.0", {"old_key1": "new_key1"}, "0.2.0" + ... )(dict_obj) + >>> dict_obj = renamed_key_items_warning( + ... "0.3.0", {"old_key2": "new_key2"}, "0.4.0" + ... )(dict_obj) + >>> dict_obj["old_key1"] + pvlibDeprecationWarning: Please use 'new_key1' instead of 'old_key1'. \ + Deprecated since 0.1.0 and will be removed in 0.2.0. + 'value1' + >>> dict_obj["old_key2"] + pvlibDeprecationWarning: Please use 'new_key2' instead of 'old_key2'. \ + Deprecated since 0.3.0 and will be removed in 0.4.0. + 'value2' + + Reusing the object wrapper factory: + + >>> dict_obj1 = {"new_key": "renamed_value", "another_key": "another_value"} + >>> dict_obj2 = {"new_key": "just_another", "yet_another_key": "yet_another_value"} + >>> wrapper_renames_old_key_to_new_key = renamed_key_items_warning("1.4.0", {"old_key": "new_key"}, "2.0.0") + >>> new_dict_obj1 = wrapper_renames_old_key_to_new_key(dict_obj1) + >>> new_dict_obj2 = wrapper_renames_old_key_to_new_key(dict_obj2) + >>> new_dict_obj1["old_key"] + :1: pvlibDeprecationWarning: Please use 'new_key' instead of 'old_key'. Deprecated since 1.4.0 and will be removed in 2.0.0. + 'renamed_value' + >>> new_dict_obj2["old_key"] + :1: pvlibDeprecationWarning: Please use 'new_key' instead of 'old_key'. Deprecated since 1.4.0 and will be removed in 2.0.0. + 'just_another' + + Notes + ----- + This decorator does not modify the way you access methods on the original + type. For example, dictionaries can only be accessed with bracketed + indexes, ``dictionary["key"]``. After decoration, ``"old_key"`` can only + be used as follows: ``dictionary["old_key"]``. Both ``dictionary.key`` and + ``dictionary.old_key`` won't become available after wrapping. + + >>> from pvlib._deprecation import renamed_key_items_warning + >>> dict_base = {"a": [1]} + >>> dict_depre = renamed_key_items_warning("0.0.1", {"b": "a"})(dict_base) + >>> dict_depre["a"] + [1] + >>> dict_depre["b"] + :1: pvlibDeprecationWarning: Please use 'a' instead of 'b'. \ + Deprecated since 0.0.1 and will be removed soon. + [1] + >>> dict_depre.a + Traceback (most recent call last): + File "", line 1, in + AttributeError: 'DeprecatedKeyItems' object has no attribute 'a' + >>> dict_depre.b + Traceback (most recent call last): + File "", line 1, in + AttributeError: 'DeprecatedKeyItems' object has no attribute 'b' + + On the other hand, ``pandas.DataFrame`` and other types may also expose + indexes as attributes on the object instance. In a ``DataFrame`` you can + either use ``df.a`` or ``df["a"]``. An old key ``b`` that maps to ``a`` + through the decorator, can either be accessed with ``df.b`` or ``df["b"]``. + + >>> from pvlib._deprecation import renamed_key_items_warning + >>> import pandas as pd + >>> df_base = pd.DataFrame({"a": [1]}) + >>> df_base.a + 0 1 + Name: a, dtype: int64 + >>> df_depre = renamed_key_items_warning("0.0.1", {"b": "a"})(df_base) + >>> df_depre.a + 0 1 + Name: a, dtype: int64 + >>> df_depre.b + Traceback (most recent call last): + File "", line 1, in + File "...", line 6299, in __getattr__ + return object.__getattribute__(self, name) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + AttributeError: 'DeprecatedKeyItems' object has no attribute 'b' + + References + ---------- + .. [1] `Python docs on __getitem__ + `_ + .. [2] `StackOverflow thread on deprecating dict keys + `_ + """ # noqa: E501 + + def deprecated(obj, old_to_new_keys_map=old_to_new_keys_map, since=since): + obj_type = type(obj) + + class DeprecatedKeyItems(obj_type): + """Handles deprecated key-indexed elements in a collection.""" + + def __getitem__(self, old_key): + if old_key in old_to_new_keys_map: + new_key = old_to_new_keys_map[old_key] + msg = ( + f"Please use '{new_key}' instead of '{old_key}'. " + f"Deprecated since {since} and will be removed " + + (f"in {removal}." if removal else "soon.") + ) + with warnings.catch_warnings(): + # by default, only first ocurrence is shown + # remove limitation to show on multiple uses + warnings.simplefilter("always") + warnings.warn( + msg, category=_projectWarning, stacklevel=2 + ) + old_key = new_key + return super().__getitem__(old_key) + + wrapped_obj = DeprecatedKeyItems(obj) + + wrapped_obj.__class__ = type( + wrapped_obj.__class__.__name__, + (DeprecatedKeyItems, obj.__class__), + {}, + ) + + return wrapped_obj + + return deprecated diff --git a/pvlib/temperature.py b/pvlib/temperature.py index 6c274d79b7..0a8c9c45ec 100644 --- a/pvlib/temperature.py +++ b/pvlib/temperature.py @@ -6,7 +6,6 @@ import numpy as np import pandas as pd from pvlib.tools import sind -from pvlib._deprecation import warn_deprecated from pvlib.tools import _get_sample_intervals import scipy import scipy.constants diff --git a/tests/test__deprecation.py b/tests/test__deprecation.py index 31263c8a25..2693fc43c7 100644 --- a/tests/test__deprecation.py +++ b/tests/test__deprecation.py @@ -3,6 +3,7 @@ """ import pytest +import pandas as pd from pvlib import _deprecation from .conftest import fail_on_pvlib_version @@ -95,3 +96,65 @@ def test_renamed_kwarg_warning(renamed_kwarg_func): TypeError, match="missing 1 required positional argument" ): renamed_kwarg_func() + + +def test_renamed_key_items_warning(): + """Test the renamed_key_items_warning decorator.""" + # Test on a dictionary + data_dict = { + "new_key1": [1, 2, 3], + "new_key2": [4, 5, 6], + "another_key": [7, 8, 9], + } + data_dict_wrapped = _deprecation.renamed_key_items_warning( + "0.1.0", {"old_key1": "new_key1"}, "0.2.0" + )(data_dict) + + # Check that the new key is present in the wrapped object + assert "new_key1" in data_dict_wrapped + assert "new_key2" in data_dict_wrapped + assert "another_key" in data_dict_wrapped + assert "old_key1" not in data_dict_wrapped + # Check that the old key still exists in the wrapped object + assert data_dict_wrapped["new_key1"] == [1, 2, 3] + assert data_dict_wrapped["new_key2"] == [4, 5, 6] + assert data_dict_wrapped["another_key"] == [7, 8, 9] + with pytest.warns(Warning, match="use 'new_key1' instead of 'old_key1'."): + assert data_dict_wrapped["old_key1"] == [1, 2, 3] + # check yet again, to ensure there is no weird persistences + with pytest.warns(Warning, match="use 'new_key1' instead of 'old_key1'."): + assert data_dict_wrapped["old_key1"] == [1, 2, 3] + + # Test on a DataFrame + data_df = pd.DataFrame(data_dict) + data_df = _deprecation.renamed_key_items_warning( + "0.1.0", {"old_key1": "new_key1", "old_key2": "new_key2"}, "0.2.0" + )(data_df) + + assert "new_key1" in data_df.columns + assert data_df.new_key1 is not None # ensure attribute access works + assert "new_key2" in data_df.columns + assert "old_key1" not in data_df.columns + assert "old_key2" not in data_df.columns + # Check that the old key still exists in the DataFrame + assert data_df["new_key1"].tolist() == [1, 2, 3] + with pytest.warns(Warning, match="use 'new_key1' instead of 'old_key1'."): + assert data_df["old_key1"].tolist() == [1, 2, 3] + with pytest.warns(Warning, match="use 'new_key1' instead of 'old_key1'."): + assert data_df["old_key1"].tolist() == [1, 2, 3] + + # Test chaining decorators, on a dict, first new_key1, then new_key2 + data_dict_wrapped = _deprecation.renamed_key_items_warning( + "0.1.0", {"old_key1": "new_key1"}, "0.2.0" + )(data_dict) + data_dict_wrapped = _deprecation.renamed_key_items_warning( + "0.3.0", {"old_key2": "new_key2"}, "0.4.0" + )(data_dict_wrapped) + # Check that the new keys are present in the wrapped object + assert "new_key1" in data_dict_wrapped + assert "new_key2" in data_dict_wrapped + + with pytest.warns(Warning, match="use 'new_key1' instead of 'old_key1'."): + assert data_dict_wrapped["old_key1"] == [1, 2, 3] + with pytest.warns(Warning, match="use 'new_key2' instead of 'old_key2'."): + assert data_dict_wrapped["old_key2"] == [4, 5, 6]