diff --git a/qiskit_algorithms/utils/deprecation.py b/qiskit_algorithms/utils/deprecation.py index cde81123..722e1075 100644 --- a/qiskit_algorithms/utils/deprecation.py +++ b/qiskit_algorithms/utils/deprecation.py @@ -14,11 +14,11 @@ import functools import warnings -from typing import Any, Dict, Optional, Type +from typing import Any, Callable, Dict, Optional, Type def deprecate_arguments( - kwarg_map: Dict[str, str], + kwarg_map: Dict[str, Optional[str]], category: Type[Warning] = DeprecationWarning, *, since: Optional[str] = None, @@ -113,3 +113,117 @@ def _rename_kwargs( ) kwargs[new_arg] = kwargs.pop(old_arg) + + +# We insert deprecations in-between the description and Napoleon's meta sections. The below is from +# https://www.sphinx-doc.org/en/master/usage/extensions/napoleon.html#docstring-sections. We use +# lowercase because Napoleon is case-insensitive. +_NAPOLEON_META_LINES = frozenset( + { + "args:", + "arguments:", + "attention:", + "attributes:", + "caution:", + "danger:", + "error:", + "example:", + "examples:", + "hint:", + "important:", + "keyword args:", + "keyword arguments:", + "note:", + "notes:", + "other parameters:", + "parameters:", + "return:", + "returns:", + "raises:", + "references:", + "see also:", + "tip:", + "todo:", + "warning:", + "warnings:", + "warn:", + "warns:", + "yield:", + "yields:", + } +) + + +def add_deprecation_to_docstring( + func: Callable, msg: str, *, since: Optional[str], pending: bool +) -> None: + """Dynamically insert the deprecation message into ``func``'s docstring. + + Args: + func: The function to modify. + msg: The full deprecation message. + since: The version the deprecation started at. + pending: Is the deprecation still pending? + """ + if "\n" in msg: + raise ValueError( + "Deprecation messages cannot contain new lines (`\\n`), but the deprecation for " + f'{func.__qualname__} had them. Usually this happens when using `"""` multiline ' + f"strings; instead, use string concatenation.\n\n" + "This is a simplification to facilitate deprecation messages being added to the " + "documentation. If you have a compelling reason to need " + "new lines, feel free to improve this function or open a request at " + "https://github.com/Qiskit/qiskit-terra/issues." + ) + + if since is None: + version_str = "unknown" + else: + version_str = f"{since}_pending" if pending else since + + indent = "" + meta_index = None + if func.__doc__: + original_lines = func.__doc__.splitlines() + content_encountered = False + for i, line in enumerate(original_lines): + stripped = line.strip() + + # Determine the indent based on the first line with content. But, we don't consider the + # first line, which corresponds to the format """Docstring.""", as it does not properly + # capture the indentation of lines beneath it. + if not content_encountered and i != 0 and stripped: + num_leading_spaces = len(line) - len(line.lstrip()) + indent = " " * num_leading_spaces + content_encountered = True + + if stripped.lower() in _NAPOLEON_META_LINES: + meta_index = i + if not content_encountered: + raise ValueError( + "add_deprecation_to_docstring cannot currently handle when a Napoleon " + "metadata line like 'Args' is the very first line of docstring, " + f'e.g. `"""Args:`. So, it cannot process {func.__qualname__}. Instead, ' + f'move the metadata line to the second line, e.g.:\n\n"""\nArgs:' + ) + # We can stop checking since we only care about the first meta line, and + # we've validated content_encountered is True to determine the indent. + break + else: + original_lines = [] + + # We defensively include new lines in the beginning and end. This is sometimes necessary, + # depending on the original docstring. It is not a big deal to have extra, other than `help()` + # being a little longer. + new_lines = [ + indent, + f"{indent}.. deprecated:: {version_str}", + f"{indent} {msg}", + indent, + ] + + if meta_index: + original_lines[meta_index - 1 : meta_index - 1] = new_lines + else: + original_lines.extend(new_lines) + func.__doc__ = "\n".join(original_lines)