diff --git a/qiskit/utils/__init__.py b/qiskit/utils/__init__.py index f4461e5ba755..8d06dd5f48d1 100644 --- a/qiskit/utils/__init__.py +++ b/qiskit/utils/__init__.py @@ -21,7 +21,10 @@ .. autosummary:: :toctree: ../stubs/ + add_deprecation_to_docstring + deprecate_arg deprecate_arguments + deprecate_func deprecate_function local_hardware_info is_main_process @@ -64,8 +67,13 @@ """ from .quantum_instance import QuantumInstance -from .deprecation import deprecate_arguments -from .deprecation import deprecate_function +from .deprecation import ( + add_deprecation_to_docstring, + deprecate_arg, + deprecate_arguments, + deprecate_func, + deprecate_function, +) from .multiprocessing import local_hardware_info from .multiprocessing import is_main_process from .units import apply_prefix, detach_prefix @@ -93,7 +101,10 @@ "has_aer", "name_args", "algorithm_globals", + "add_deprecation_to_docstring", + "deprecate_arg", "deprecate_arguments", + "deprecate_func", "deprecate_function", "local_hardware_info", "is_main_process", diff --git a/qiskit/utils/deprecation.py b/qiskit/utils/deprecation.py index 6e91657e91ab..77f5b43e5e6a 100644 --- a/qiskit/utils/deprecation.py +++ b/qiskit/utils/deprecation.py @@ -14,7 +14,173 @@ import functools import warnings -from typing import Any, Callable, Dict, Optional, Type +from typing import Any, Callable, Dict, Optional, Type, Tuple, Union + + +def deprecate_func( + *, + since: str, + additional_msg: Optional[str] = None, + pending: bool = False, + package_name: str = "qiskit-terra", + removal_timeline: str = "no earlier than 3 months after the release date", + is_property: bool = False, +): + """Decorator to indicate a function has been deprecated. + + It should be placed beneath other decorators like `@staticmethod` and property decorators. + + When deprecating a class, set this decorator on its `__init__` function. + + Args: + since: The version the deprecation started at. If the deprecation is pending, set + the version to when that started; but later, when switching from pending to + deprecated, update ``since`` to the new version. + additional_msg: Put here any additional information, such as what to use instead. + For example, "Instead, use the function ``new_func`` from the module + ``.``, which is similar but uses GPU acceleration." + pending: Set to ``True`` if the deprecation is still pending. + package_name: The PyPI package name, e.g. "qiskit-nature". + removal_timeline: How soon can this deprecation be removed? Expects a value + like "no sooner than 6 months after the latest release" or "in release 9.99". + is_property: If the deprecated function is a `@property`, set this to True so that the + generated message correctly describes it as such. (This isn't necessary for + property setters, as their docstring is ignored by Python.) + + Returns: + Callable: The decorated callable. + """ + + def decorator(func): + qualname = func.__qualname__ # For methods, `qualname` includes the class name. + mod_name = func.__module__ + + # Detect what function type this is. + if is_property: + # `inspect.isdatadescriptor()` doesn't work because you must apply our decorator + # before `@property`, so it looks like the function is a normal method. + deprecated_entity = f"The property ``{mod_name}.{qualname}``" + # To determine if's a method, we use the heuristic of looking for a `.` in the qualname. + # This is because top-level functions will only have the function name. This is not + # perfect, e.g. it incorrectly classifies nested/inner functions, but we don't expect + # those to be deprecated. + # + # We can't use `inspect.ismethod()` because that only works when calling it on an instance + # of the class, rather than the class type itself, i.e. `ismethod(C().foo)` vs + # `ismethod(C.foo)`. + elif "." in qualname: + if func.__name__ == "__init__": + cls_name = qualname[: -len(".__init__")] + deprecated_entity = f"The class ``{mod_name}.{cls_name}``" + else: + deprecated_entity = f"The method ``{mod_name}.{qualname}()``" + else: + deprecated_entity = f"The function ``{mod_name}.{qualname}()``" + + msg, category = _write_deprecation_msg( + deprecated_entity=deprecated_entity, + package_name=package_name, + since=since, + pending=pending, + additional_msg=additional_msg, + removal_timeline=removal_timeline, + ) + + @functools.wraps(func) + def wrapper(*args, **kwargs): + warnings.warn(msg, category=category, stacklevel=2) + return func(*args, **kwargs) + + add_deprecation_to_docstring(wrapper, msg, since=since, pending=pending) + return wrapper + + return decorator + + +def deprecate_arg( + name: str, + *, + since: str, + additional_msg: Optional[str] = None, + deprecation_description: Optional[str] = None, + pending: bool = False, + package_name: str = "qiskit-terra", + new_alias: Optional[str] = None, + predicate: Optional[Callable[[Any], bool]] = None, + removal_timeline: str = "no earlier than 3 months after the release date", +): + """Decorator to indicate an argument has been deprecated in some way. + + This decorator may be used multiple times on the same function, once per deprecated argument. + It should be placed beneath other decorators like ``@staticmethod`` and property decorators. + + Args: + name: The name of the deprecated argument. + since: The version the deprecation started at. If the deprecation is pending, set + the version to when that started; but later, when switching from pending to + deprecated, update `since` to the new version. + deprecation_description: What is being deprecated? E.g. "Setting my_func()'s `my_arg` + argument to `None`." If not set, will default to "{func_name}'s argument `{name}`". + additional_msg: Put here any additional information, such as what to use instead + (if new_alias is not set). For example, "Instead, use the argument `new_arg`, + which is similar but does not impact the circuit's setup." + pending: Set to `True` if the deprecation is still pending. + package_name: The PyPI package name, e.g. "qiskit-nature". + new_alias: If the arg has simply been renamed, set this to the new name. The decorator will + dynamically update the `kwargs` so that when the user sets the old arg, it will be + passed in as the `new_alias` arg. + predicate: Only log the runtime warning if the predicate returns True. This is useful to + deprecate certain values or types for an argument, e.g. + `lambda my_arg: isinstance(my_arg, dict)`. Regardless of if a predicate is set, the + runtime warning will only log when the user specifies the argument. + removal_timeline: How soon can this deprecation be removed? Expects a value + like "no sooner than 6 months after the latest release" or "in release 9.99". + + Returns: + Callable: The decorated callable. + """ + + def decorator(func): + # For methods, `__qualname__` includes the class name. + func_name = f"{func.__module__}.{func.__qualname__}()" + deprecated_entity = deprecation_description or f"``{func_name}``'s argument ``{name}``" + + if new_alias: + alias_msg = f"Instead, use the argument ``{new_alias}``, which behaves identically." + if additional_msg: + final_additional_msg = f"{alias_msg}. {additional_msg}" + else: + final_additional_msg = alias_msg + else: + final_additional_msg = additional_msg + + msg, category = _write_deprecation_msg( + deprecated_entity=deprecated_entity, + package_name=package_name, + since=since, + pending=pending, + additional_msg=final_additional_msg, + removal_timeline=removal_timeline, + ) + + @functools.wraps(func) + def wrapper(*args, **kwargs): + if kwargs: + _maybe_warn_and_rename_kwarg( + func_name, + kwargs, + old_arg=name, + new_alias=new_alias, + warning_msg=msg, + category=category, + predicate=predicate, + ) + return func(*args, **kwargs) + + add_deprecation_to_docstring(wrapper, msg, since=since, pending=pending) + return wrapper + + return decorator def deprecate_arguments( @@ -23,7 +189,7 @@ def deprecate_arguments( *, since: Optional[str] = None, ): - """Decorator to automatically alias deprecated argument names and warn upon use. + """Deprecated. Instead, use `@deprecate_arg`. Args: kwarg_map: A dictionary of the old argument name to the new name. @@ -51,7 +217,16 @@ def decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): if kwargs: - _rename_kwargs(func_name, kwargs, old_kwarg_to_msg, kwarg_map, category) + for old, new in kwarg_map.items(): + _maybe_warn_and_rename_kwarg( + func_name, + kwargs, + old_arg=old, + new_alias=new, + warning_msg=old_kwarg_to_msg[old], + category=category, + predicate=None, + ) return func(*args, **kwargs) for msg in old_kwarg_to_msg.values(): @@ -70,7 +245,7 @@ def deprecate_function( *, since: Optional[str] = None, ): - """Emit a warning prior to calling decorated function. + """Deprecated. Instead, use `@deprecate_func`. Args: msg: Warning message to emit. @@ -99,21 +274,52 @@ def wrapper(*args, **kwargs): return decorator -def _rename_kwargs( +def _maybe_warn_and_rename_kwarg( func_name: str, kwargs: Dict[str, Any], - old_kwarg_to_msg: Dict[str, str], - kwarg_map: Dict[str, Optional[str]], - category: Type[Warning] = DeprecationWarning, + *, + old_arg: str, + new_alias: Optional[str], + warning_msg: str, + category: Type[Warning], + predicate: Optional[Callable[[Any], bool]], ) -> None: - for old_arg, new_arg in kwarg_map.items(): - if old_arg not in kwargs: - continue - if new_arg in kwargs: - raise TypeError(f"{func_name} received both {new_arg} and {old_arg} (deprecated).") - warnings.warn(old_kwarg_to_msg[old_arg], category=category, stacklevel=3) - if new_arg is not None: - kwargs[new_arg] = kwargs.pop(old_arg) + if old_arg not in kwargs: + return + if new_alias and new_alias in kwargs: + raise TypeError(f"{func_name} received both {new_alias} and {old_arg} (deprecated).") + if predicate and not predicate(kwargs[old_arg]): + return + warnings.warn(warning_msg, category=category, stacklevel=3) + if new_alias is not None: + kwargs[new_alias] = kwargs.pop(old_arg) + + +def _write_deprecation_msg( + *, + deprecated_entity: str, + package_name: str, + since: str, + pending: bool, + additional_msg: str, + removal_timeline: str, +) -> Tuple[str, Union[Type[DeprecationWarning], Type[PendingDeprecationWarning]]]: + if pending: + category = PendingDeprecationWarning + deprecation_status = "pending deprecation" + removal_desc = f"marked deprecated in a future release, and then removed {removal_timeline}" + else: + category = DeprecationWarning + deprecation_status = "deprecated" + removal_desc = f"removed {removal_timeline}" + + msg = ( + f"{deprecated_entity} is {deprecation_status} as of {package_name} {since}. " + f"It will be {removal_desc}." + ) + if additional_msg: + msg += f" {additional_msg}" + return msg, category # We insert deprecations in-between the description and Napoleon's meta sections. The below is from diff --git a/releasenotes/notes/new-deprecation-utilities-066aff05e221d7b1.yaml b/releasenotes/notes/new-deprecation-utilities-066aff05e221d7b1.yaml index c450bd76fcbf..ede00c05a0a0 100644 --- a/releasenotes/notes/new-deprecation-utilities-066aff05e221d7b1.yaml +++ b/releasenotes/notes/new-deprecation-utilities-066aff05e221d7b1.yaml @@ -1,8 +1,18 @@ --- features: - | - Added the function ``qiskit.util.deprecation.add_deprecation_to_docstring()``. - It will rewrite the function's docstring to include a Sphinx ``.. deprecated::` directive - so that the deprecation shows up in docs and with ``help()``. The deprecation decorators - from ``qiskit.util.deprecation`` call ``add_deprecation_to_docstring()`` already for you; - but you can call it directly if you are using different mechanisms for deprecations. + Added the functions ``add_deprecation_to_docstring()``, ``@deprecate_arg``, and + ``@deprecate_func`` to ``qiskit.util.deprecation``. + + ``add_deprecation_to_docstring()`` will rewrite the function's docstring to include a + Sphinx ``.. deprecated::`` directive so that the deprecation shows up in docs and with + ``help()``. The deprecation decorators from ``qiskit.util.deprecation`` call + ``add_deprecation_to_docstring()`` already for you; but you can call it directly if you + are using different mechanisms for deprecations. + + ``@deprecate_func`` replaces ``@deprecate_function``. It will auto-generate most of the + deprecation message for you. + + ``@deprecate_arg`` replaces ``@deprecate_arguments``. It will generate a more useful message + than before. It is also more flexible, like allowing you to set a ``predicate`` so that you + only deprecate certain situations, such as using a deprecated value or data type. diff --git a/test/python/utils/test_deprecation.py b/test/python/utils/test_deprecation.py index a69289ce362d..c9013f39f94b 100644 --- a/test/python/utils/test_deprecation.py +++ b/test/python/utils/test_deprecation.py @@ -17,16 +17,151 @@ from qiskit.test import QiskitTestCase from qiskit.utils.deprecation import ( add_deprecation_to_docstring, - deprecate_function, + deprecate_arg, deprecate_arguments, + deprecate_func, + deprecate_function, +) + + +@deprecate_func( + since="9.99", + additional_msg="Instead, use new_func().", + removal_timeline="in 2 releases", ) +def _deprecated_func(): + pass + + +class _Foo: + @deprecate_func(since="9.99", pending=True) + def __init__(self): + super().__init__() + + @property + @deprecate_func(since="9.99", is_property=True) + def my_property(self): + """Property.""" + return 0 + + @my_property.setter + @deprecate_func(since="9.99") + def my_property(self, value): + pass + + @deprecate_func(since="9.99", additional_msg="Stop using this!") + def my_method(self): + """Method.""" + + def normal_method(self): + """Method.""" class TestDeprecationDecorators(QiskitTestCase): """Test that the decorators in ``utils.deprecation`` correctly log warnings and get added to docstring.""" - def test_deprecate_arguments_message(self) -> None: + def test_deprecate_func_docstring(self) -> None: + """Test that `@deprecate_func` adds the correct message to the docstring.""" + + self.assertEqual( + _deprecated_func.__doc__, + dedent( + f"""\ + + .. deprecated:: 9.99 + The function ``{__name__}._deprecated_func()`` is deprecated as of qiskit-terra \ +9.99. It will be removed in 2 releases. Instead, use new_func(). + """ + ), + ) + self.assertEqual( + _Foo.__init__.__doc__, + dedent( + f"""\ + + .. deprecated:: 9.99_pending + The class ``{__name__}._Foo`` is pending deprecation as of qiskit-terra 9.99. It \ +will be marked deprecated in a future release, and then removed no earlier than 3 months after \ +the release date. + """ + ), + ) + self.assertEqual( + _Foo.my_method.__doc__, + dedent( + f"""\ + Method. + + .. deprecated:: 9.99 + The method ``{__name__}._Foo.my_method()`` is deprecated as of qiskit-terra \ +9.99. It will be removed no earlier than 3 months after the release date. Stop using this! + """ + ), + ) + self.assertEqual( + _Foo.my_property.__doc__, + dedent( + f"""\ + Property. + + .. deprecated:: 9.99 + The property ``{__name__}._Foo.my_property`` is deprecated as of qiskit-terra \ +9.99. It will be removed no earlier than 3 months after the release date. + """ + ), + ) + + def test_deprecate_arg_docstring(self) -> None: + """Test that `@deprecate_arg` adds the correct message to the docstring.""" + + @deprecate_arg("arg1", since="9.99", removal_timeline="in 2 releases") + @deprecate_arg("arg2", pending=True, since="9.99") + @deprecate_arg( + "arg3", + since="9.99", + deprecation_description="Using the argument arg3", + new_alias="new_arg3", + ) + @deprecate_arg( + "arg4", + since="9.99", + additional_msg="Instead, use foo.", + # This predicate always fails, but it should not impact storing the deprecation + # metadata. That ensures the deprecation still shows up in our docs. + predicate=lambda arg4: False, + ) + def my_func() -> None: + pass + + self.assertEqual( + my_func.__doc__, + dedent( + f"""\ + + .. deprecated:: 9.99 + ``{__name__}.{my_func.__qualname__}()``'s argument ``arg4`` is deprecated as of \ +qiskit-terra 9.99. It will be removed no earlier than 3 months after the release date. Instead, \ +use foo. + + .. deprecated:: 9.99 + Using the argument arg3 is deprecated as of qiskit-terra 9.99. It will be \ +removed no earlier than 3 months after the release date. Instead, use the argument ``new_arg3``, \ +which behaves identically. + + .. deprecated:: 9.99_pending + ``{__name__}.{my_func.__qualname__}()``'s argument ``arg2`` is pending \ +deprecation as of qiskit-terra 9.99. It will be marked deprecated in a future release, and then \ +removed no earlier than 3 months after the release date. + + .. deprecated:: 9.99 + ``{__name__}.{my_func.__qualname__}()``'s argument ``arg1`` is deprecated as of \ +qiskit-terra 9.99. It will be removed in 2 releases. + """ + ), + ) + + def test_deprecate_arguments_docstring(self) -> None: """Test that `@deprecate_arguments` adds the correct message to the docstring.""" @deprecate_arguments( @@ -71,6 +206,49 @@ def my_func() -> None: ), ) + def test_deprecate_func_runtime_warning(self) -> None: + """Test that `@deprecate_func` warns whenever the function is used.""" + + with self.assertWarns(DeprecationWarning): + _deprecated_func() + with self.assertWarns(PendingDeprecationWarning): + instance = _Foo() + with self.assertWarns(DeprecationWarning): + instance.my_method() + with self.assertWarns(DeprecationWarning): + _ = instance.my_property + with self.assertWarns(DeprecationWarning): + instance.my_property = 1 + instance.normal_method() + + def test_deprecate_arg_runtime_warning(self) -> None: + """Test that `@deprecate_arg` warns whenever the arguments are used. + + Also check these edge cases: + * If `new_alias` is set, pass the old argument as the new alias. + * If `predicate` is set, only warn if the predicate is True. + """ + + @deprecate_arg("arg1", since="9.99") + @deprecate_arg("arg2", new_alias="new_arg2", since="9.99") + @deprecate_arg("arg3", predicate=lambda arg3: arg3 == "deprecated value", since="9.99") + def my_func(*, arg1: str = "a", new_arg2: str, arg3: str = "a") -> None: + del arg1 + del arg3 + assert new_arg2 == "z" + + my_func(new_arg2="z") # No warnings if no deprecated args used. + with self.assertWarnsRegex(DeprecationWarning, "arg1"): + my_func(arg1="a", new_arg2="z") + with self.assertWarnsRegex(DeprecationWarning, "arg2"): + # `arg2` should be converted into `new_arg2`. + my_func(arg2="z") # pylint: disable=missing-kwoa + + # Test the `predicate` functionality. + my_func(new_arg2="z", arg3="okay value") + with self.assertWarnsRegex(DeprecationWarning, "arg3"): + my_func(new_arg2="z", arg3="deprecated value") + def test_deprecate_arguments_runtime_warning(self) -> None: """Test that `@deprecate_arguments` warns whenever the arguments are used.