Skip to content

[DEVOPS] Deprecation warning wrapper for dict-like object renamed keys #2530

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 6 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
73 changes: 73 additions & 0 deletions docs/sphinx/source/contributing/devops.rst
Original file line number Diff line number Diff line change
@@ -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 <https://docs.python.org/3/glossary.html#term-parameter>`_.

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`
1 change: 1 addition & 0 deletions docs/sphinx/source/contributing/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@ Contributing
how_to_contribute_new_code
style_guide
testing
devops
224 changes: 211 additions & 13 deletions pvlib/_deprecation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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,
Expand All @@ -217,15 +220,16 @@ def deprecated(since, message='', name='', alternative='', pending=False,
addendum='', removal=''):
"""
Decorator to mark a function or a class as deprecated.

Parameters
----------
since : str
The release at which this API became deprecated. This is
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
Expand All @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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
----------
Expand Down Expand Up @@ -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)
<class 'pvlib._deprecation.DeprecatedKeyItems'>

>>> 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"]
<stdin>: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"]
<stdin>: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"]
<stdin>: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 "<stdin>", line 1, in <module>
AttributeError: 'DeprecatedKeyItems' object has no attribute 'a'
>>> dict_depre.b
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
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 "<stdin>", line 1, in <module>
File "...", line 6299, in __getattr__
return object.__getattribute__(self, name)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AttributeError: 'DeprecatedKeyItems' object has no attribute 'b'

References
----------
.. [1] `Python docs on __getitem__
<https://docs.python.org/3/reference/datamodel.html#object.__getitem__>`_
.. [2] `StackOverflow thread on deprecating dict keys
<https://stackoverflow.com/questions/54095279/how-to-make-a-dict-key-deprecated>`_
""" # 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
1 change: 0 additions & 1 deletion pvlib/temperature.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading