# -*- test-case-name: twisted.python.test.test_deprecate -*-
# Copyright (c) Twisted Matrix Laboratories.
# See LICENSE for details.

"""
Deprecation framework for Twisted.

To mark a method, function, or class as being deprecated do this::

    from incremental import Version
    from twisted.python.deprecate import deprecated

    @deprecated(Version("Twisted", 22, 10, 0))
    def badAPI(self, first, second):
        '''
        Docstring for badAPI.
        '''
        ...

    @deprecated(Version("Twisted", 22, 10, 0))
    class BadClass:
        '''
        Docstring for BadClass.
        '''

The newly-decorated badAPI will issue a warning when called, and BadClass will
issue a warning when instantiated. Both will also have  a deprecation notice
appended to their docstring.

To deprecate properties you can use::

    from incremental import Version
    from twisted.python.deprecate import deprecatedProperty

    class OtherwiseUndeprecatedClass:

        @deprecatedProperty(Version("Twisted", 22, 10, 0))
        def badProperty(self):
            '''
            Docstring for badProperty.
            '''

        @badProperty.setter
        def badProperty(self, value):
            '''
            Setter sill also raise the deprecation warning.
            '''


While it's best to avoid this as it adds performance overhead to *any* usage of
the module, to mark module-level attributes as being deprecated you can use::

    badAttribute = "someValue"

    ...

    deprecatedModuleAttribute(
        Version("Twisted", 22, 10, 0),
        "Use goodAttribute instead.",
        "your.full.module.name",
        "badAttribute")

The deprecated attributes will issue a warning whenever they are accessed. If
the attributes being deprecated are in the same module as the
L{deprecatedModuleAttribute} call is being made from, the C{__name__} global
can be used as the C{moduleName} parameter.


To mark an optional, keyword parameter of a function or method as deprecated
without deprecating the function itself, you can use::

    @deprecatedKeywordParameter(Version("Twisted", 22, 10, 0), "baz")
    def someFunction(foo, bar=0, baz=None):
        ...

See also L{incremental.Version}.

@type DEPRECATION_WARNING_FORMAT: C{str}
@var DEPRECATION_WARNING_FORMAT: The default deprecation warning string format
    to use when one is not provided by the user.
"""
from __future__ import annotations

__all__ = [
    "deprecated",
    "deprecatedProperty",
    "getDeprecationWarningString",
    "getWarningMethod",
    "setWarningMethod",
    "deprecatedModuleAttribute",
    "deprecatedKeywordParameter",
]


import inspect
import sys
from dis import findlinestarts
from functools import wraps
from types import ModuleType
from typing import Any, Callable, Dict, Optional, Sequence, TypeVar, cast
from warnings import warn, warn_explicit

from incremental import Version, getVersionString
from typing_extensions import ParamSpec

_P = ParamSpec("_P")
_R = TypeVar("_R")

DEPRECATION_WARNING_FORMAT = "%(fqpn)s was deprecated in %(version)s"

# Notionally, part of twisted.python.reflect, but defining it there causes a
# cyclic dependency between this module and that module.  Define it here,
# instead, and let reflect import it to re-expose to the public.


def _fullyQualifiedName(obj):
    """
    Return the fully qualified name of a module, class, method or function.
    Classes and functions need to be module level ones to be correctly
    qualified.

    @rtype: C{str}.
    """
    try:
        name = obj.__qualname__
    except AttributeError:
        name = obj.__name__

    if inspect.isclass(obj) or inspect.isfunction(obj):
        moduleName = obj.__module__
        return f"{moduleName}.{name}"
    elif inspect.ismethod(obj):
        return f"{obj.__module__}.{obj.__qualname__}"
    return name


# Try to keep it looking like something in twisted.python.reflect.
_fullyQualifiedName.__module__ = "twisted.python.reflect"
_fullyQualifiedName.__name__ = "fullyQualifiedName"
_fullyQualifiedName.__qualname__ = "fullyQualifiedName"


def _getReplacementString(replacement):
    """
    Surround a replacement for a deprecated API with some polite text exhorting
    the user to consider it as an alternative.

    @type replacement: C{str} or callable

    @return: a string like "please use twisted.python.modules.getModule
        instead".
    """
    if callable(replacement):
        replacement = _fullyQualifiedName(replacement)
    return f"please use {replacement} instead"


def _getDeprecationDocstring(version, replacement=None):
    """
    Generate an addition to a deprecated object's docstring that explains its
    deprecation.

    @param version: the version it was deprecated.
    @type version: L{incremental.Version}

    @param replacement: The replacement, if specified.
    @type replacement: C{str} or callable

    @return: a string like "Deprecated in Twisted 27.2.0; please use
        twisted.timestream.tachyon.flux instead."
    """
    doc = f"Deprecated in {getVersionString(version)}"
    if replacement:
        doc = f"{doc}; {_getReplacementString(replacement)}"
    return doc + "."


def _getDeprecationWarningString(fqpn, version, format=None, replacement=None):
    """
    Return a string indicating that the Python name was deprecated in the given
    version.

    @param fqpn: Fully qualified Python name of the thing being deprecated
    @type fqpn: C{str}

    @param version: Version that C{fqpn} was deprecated in.
    @type version: L{incremental.Version}

    @param format: A user-provided format to interpolate warning values into, or
        L{DEPRECATION_WARNING_FORMAT
        <twisted.python.deprecate.DEPRECATION_WARNING_FORMAT>} if L{None} is
        given.
    @type format: C{str}

    @param replacement: what should be used in place of C{fqpn}. Either pass in
        a string, which will be inserted into the warning message, or a
        callable, which will be expanded to its full import path.
    @type replacement: C{str} or callable

    @return: A textual description of the deprecation
    @rtype: C{str}
    """
    if format is None:
        format = DEPRECATION_WARNING_FORMAT
    warningString = format % {"fqpn": fqpn, "version": getVersionString(version)}
    if replacement:
        warningString = "{}; {}".format(
            warningString, _getReplacementString(replacement)
        )
    return warningString


def getDeprecationWarningString(callableThing, version, format=None, replacement=None):
    """
    Return a string indicating that the callable was deprecated in the given
    version.

    @type callableThing: C{callable}
    @param callableThing: Callable object to be deprecated

    @type version: L{incremental.Version}
    @param version: Version that C{callableThing} was deprecated in.

    @type format: C{str}
    @param format: A user-provided format to interpolate warning values into,
        or L{DEPRECATION_WARNING_FORMAT
        <twisted.python.deprecate.DEPRECATION_WARNING_FORMAT>} if L{None} is
        given

    @param replacement: what should be used in place of the callable. Either
        pass in a string, which will be inserted into the warning message,
        or a callable, which will be expanded to its full import path.
    @type replacement: C{str} or callable

    @return: A string describing the deprecation.
    @rtype: C{str}
    """
    return _getDeprecationWarningString(
        _fullyQualifiedName(callableThing), version, format, replacement
    )


def _appendToDocstring(thingWithDoc, textToAppend):
    """
    Append the given text to the docstring of C{thingWithDoc}.

    If C{thingWithDoc} has no docstring, then the text just replaces the
    docstring. If it has a single-line docstring then it appends a blank line
    and the message text. If it has a multi-line docstring, then in appends a
    blank line a the message text, and also does the indentation correctly.
    """
    if thingWithDoc.__doc__:
        docstringLines = thingWithDoc.__doc__.splitlines()
    else:
        docstringLines = []

    if len(docstringLines) == 0:
        docstringLines.append(textToAppend)
    elif len(docstringLines) == 1:
        docstringLines.extend(["", textToAppend, ""])
    else:
        trailer = docstringLines[-1]
        spaces = ""
        if not trailer.strip():
            # Deal with differences between Python 3.13 and older versions.
            spaces = docstringLines.pop()
        docstringLines.extend(["", spaces + textToAppend, spaces])
        docstringLines = [l.lstrip(" ") for l in docstringLines]
    thingWithDoc.__doc__ = "\n".join(docstringLines)


def deprecated(
    version: Version, replacement: str | Callable[..., object] | None = None
) -> Callable[[Callable[_P, _R]], Callable[_P, _R]]:
    """
    Return a decorator that marks callables as deprecated. To deprecate a
    property, see L{deprecatedProperty}.

    @type version: L{incremental.Version}
    @param version: The version in which the callable will be marked as
        having been deprecated.  The decorated function will be annotated
        with this version, having it set as its C{deprecatedVersion}
        attribute.

    @param replacement: what should be used in place of the callable. Either
        pass in a string, which will be inserted into the warning message,
        or a callable, which will be expanded to its full import path.
    @type replacement: C{str} or callable
    """

    def deprecationDecorator(function: Callable[_P, _R]) -> Callable[_P, _R]:
        """
        Decorator that marks C{function} as deprecated.
        """
        warningString = getDeprecationWarningString(
            function, version, None, replacement
        )

        @wraps(function)
        def deprecatedFunction(*args: _P.args, **kwargs: _P.kwargs) -> _R:
            warn(warningString, DeprecationWarning, stacklevel=2)
            return function(*args, **kwargs)

        _appendToDocstring(
            deprecatedFunction, _getDeprecationDocstring(version, replacement)
        )
        deprecatedFunction.deprecatedVersion = version  # type: ignore[attr-defined]
        return deprecatedFunction

    return deprecationDecorator


def deprecatedProperty(
    version: Version, replacement: str | Callable[..., object] | None = None
) -> Callable[[Callable[_P, _R]], Callable[_P, _R]]:
    """
    Return a decorator that marks a property as deprecated. To deprecate a
    regular callable or class, see L{deprecated}.

    @type version: L{incremental.Version}
    @param version: The version in which the callable will be marked as
        having been deprecated.  The decorated function will be annotated
        with this version, having it set as its C{deprecatedVersion}
        attribute.

    @param replacement: what should be used in place of the callable.
        Either pass in a string, which will be inserted into the warning
        message, or a callable, which will be expanded to its full import
        path.
    @type replacement: C{str} or callable

    @return: A new property with deprecated setter and getter.
    @rtype: C{property}

    @since: 16.1.0
    """

    class _DeprecatedProperty(property):
        """
        Extension of the build-in property to allow deprecated setters.
        """

        def _deprecatedWrapper(self, function):
            @wraps(function)
            def deprecatedFunction(*args, **kwargs):
                warn(
                    self.warningString,  # type: ignore[attr-defined]
                    DeprecationWarning,
                    stacklevel=2,
                )
                return function(*args, **kwargs)

            return deprecatedFunction

        def setter(self, function):
            return property.setter(self, self._deprecatedWrapper(function))

    def deprecationDecorator(function):
        warningString = getDeprecationWarningString(
            function, version, None, replacement
        )

        @wraps(function)
        def deprecatedFunction(*args, **kwargs):
            warn(warningString, DeprecationWarning, stacklevel=2)
            return function(*args, **kwargs)

        _appendToDocstring(
            deprecatedFunction, _getDeprecationDocstring(version, replacement)
        )
        deprecatedFunction.deprecatedVersion = version  # type: ignore[attr-defined]

        result = _DeprecatedProperty(deprecatedFunction)
        result.warningString = warningString  # type: ignore[attr-defined]
        return result

    return deprecationDecorator


def getWarningMethod():
    """
    Return the warning method currently used to record deprecation warnings.
    """
    return warn


def setWarningMethod(newMethod):
    """
    Set the warning method to use to record deprecation warnings.

    The callable should take message, category and stacklevel. The return
    value is ignored.
    """
    global warn
    warn = newMethod


class _InternalState:
    """
    An L{_InternalState} is a helper object for a L{_ModuleProxy}, so that it
    can easily access its own attributes, bypassing its logic for delegating to
    another object that it's proxying for.

    @ivar proxy: a L{_ModuleProxy}
    """

    def __init__(self, proxy):
        object.__setattr__(self, "proxy", proxy)

    def __getattribute__(self, name):
        return object.__getattribute__(object.__getattribute__(self, "proxy"), name)

    def __setattr__(self, name, value):
        return object.__setattr__(object.__getattribute__(self, "proxy"), name, value)


class _ModuleProxy:
    """
    Python module wrapper to hook module-level attribute access.

    Access to deprecated attributes first checks
    L{_ModuleProxy._deprecatedAttributes}, if the attribute does not appear
    there then access falls through to L{_ModuleProxy._module}, the wrapped
    module object.

    @ivar _module: Module on which to hook attribute access.
    @type _module: C{module}

    @ivar _deprecatedAttributes: Mapping of attribute names to objects that
        retrieve the module attribute's original value.
    @type _deprecatedAttributes: C{dict} mapping C{str} to
        L{_DeprecatedAttribute}

    @ivar _lastWasPath: Heuristic guess as to whether warnings about this
        package should be ignored for the next call.  If the last attribute
        access of this module was a C{getattr} of C{__path__}, we will assume
        that it was the import system doing it and we won't emit a warning for
        the next access, even if it is to a deprecated attribute.  The CPython
        import system always tries to access C{__path__}, then the attribute
        itself, then the attribute itself again, in both successful and failed
        cases.
    @type _lastWasPath: C{bool}
    """

    def __init__(self, module):
        state = _InternalState(self)
        state._module = module
        state._deprecatedAttributes = {}
        state._lastWasPath = False

    def __repr__(self) -> str:
        """
        Get a string containing the type of the module proxy and a
        representation of the wrapped module object.
        """
        state = _InternalState(self)
        return f"<{type(self).__name__} module={state._module!r}>"

    def __setattr__(self, name, value):
        """
        Set an attribute on the wrapped module object.
        """
        state = _InternalState(self)
        state._lastWasPath = False
        setattr(state._module, name, value)

    def __getattribute__(self, name):
        """
        Get an attribute from the module object, possibly emitting a warning.

        If the specified name has been deprecated, then a warning is issued.
        (Unless certain obscure conditions are met; see
        L{_ModuleProxy._lastWasPath} for more information about what might quash
        such a warning.)
        """
        state = _InternalState(self)
        if state._lastWasPath:
            deprecatedAttribute = None
        else:
            deprecatedAttribute = state._deprecatedAttributes.get(name)

        if deprecatedAttribute is not None:
            # If we have a _DeprecatedAttribute object from the earlier lookup,
            # allow it to issue the warning.
            value = deprecatedAttribute.get()
        else:
            # Otherwise, just retrieve the underlying value directly; it's not
            # deprecated, there's no warning to issue.
            value = getattr(state._module, name)
        if name == "__path__":
            state._lastWasPath = True
        else:
            state._lastWasPath = False
        return value


class _DeprecatedAttribute:
    """
    Wrapper for deprecated attributes.

    This is intended to be used by L{_ModuleProxy}. Calling
    L{_DeprecatedAttribute.get} will issue a warning and retrieve the
    underlying attribute's value.

    @type module: C{module}
    @ivar module: The original module instance containing this attribute

    @type fqpn: C{str}
    @ivar fqpn: Fully qualified Python name for the deprecated attribute

    @type version: L{incremental.Version}
    @ivar version: Version that the attribute was deprecated in

    @type message: C{str}
    @ivar message: Deprecation message
    """

    def __init__(self, module, name, version, message):
        """
        Initialise a deprecated name wrapper.
        """
        self.module = module
        self.__name__ = name
        self.fqpn = module.__name__ + "." + name
        self.version = version
        self.message = message

    def get(self):
        """
        Get the underlying attribute value and issue a deprecation warning.
        """
        # This might fail if the deprecated thing is a module inside a package.
        # In that case, don't emit the warning this time.  The import system
        # will come back again when it's not an AttributeError and we can emit
        # the warning then.
        result = getattr(self.module, self.__name__)
        message = _getDeprecationWarningString(
            self.fqpn, self.version, DEPRECATION_WARNING_FORMAT + ": " + self.message
        )
        warn(message, DeprecationWarning, stacklevel=3)
        return result


def _deprecateAttribute(proxy, name, version, message):
    """
    Mark a module-level attribute as being deprecated.

    @type proxy: L{_ModuleProxy}
    @param proxy: The module proxy instance proxying the deprecated attributes

    @type name: C{str}
    @param name: Attribute name

    @type version: L{incremental.Version}
    @param version: Version that the attribute was deprecated in

    @type message: C{str}
    @param message: Deprecation message
    """
    _module = object.__getattribute__(proxy, "_module")
    attr = _DeprecatedAttribute(_module, name, version, message)
    # Add a deprecated attribute marker for this module's attribute. When this
    # attribute is accessed via _ModuleProxy a warning is emitted.
    _deprecatedAttributes = object.__getattribute__(proxy, "_deprecatedAttributes")
    _deprecatedAttributes[name] = attr


def deprecatedModuleAttribute(version, message, moduleName, name):
    """
    Declare a module-level attribute as being deprecated.

    @type version: L{incremental.Version}
    @param version: Version that the attribute was deprecated in

    @type message: C{str}
    @param message: Deprecation message

    @type moduleName: C{str}
    @param moduleName: Fully-qualified Python name of the module containing
        the deprecated attribute; if called from the same module as the
        attributes are being deprecated in, using the C{__name__} global can
        be helpful

    @type name: C{str}
    @param name: Attribute name to deprecate
    """
    module = sys.modules[moduleName]
    if not isinstance(module, _ModuleProxy):
        module = cast(ModuleType, _ModuleProxy(module))
        sys.modules[moduleName] = module

    _deprecateAttribute(module, name, version, message)


def warnAboutFunction(offender, warningString):
    """
    Issue a warning string, identifying C{offender} as the responsible code.

    This function is used to deprecate some behavior of a function.  It differs
    from L{warnings.warn} in that it is not limited to deprecating the behavior
    of a function currently on the call stack.

    @param offender: The function that is being deprecated.

    @param warningString: The string that should be emitted by this warning.
    @type warningString: C{str}

    @since: 11.0
    """
    # inspect.getmodule() is attractive, but somewhat
    # broken in Python < 2.6.  See Python bug 4845.
    # In Python 3.13 line numbers returned by findlinestarts
    # can be None for bytecode that does not map to source
    # lines.
    offenderModule = sys.modules[offender.__module__]
    warn_explicit(
        warningString,
        category=DeprecationWarning,
        filename=inspect.getabsfile(offenderModule),
        lineno=max(
            lineNumber
            for _, lineNumber in findlinestarts(offender.__code__)
            if lineNumber is not None
        ),
        module=offenderModule.__name__,
        registry=offender.__globals__.setdefault("__warningregistry__", {}),
        module_globals=None,
    )


def _passedArgSpec(argspec, positional, keyword):
    """
    Take an I{inspect.ArgSpec}, a tuple of positional arguments, and a dict of
    keyword arguments, and return a mapping of arguments that were actually
    passed to their passed values.

    @param argspec: The argument specification for the function to inspect.
    @type argspec: I{inspect.ArgSpec}

    @param positional: The positional arguments that were passed.
    @type positional: L{tuple}

    @param keyword: The keyword arguments that were passed.
    @type keyword: L{dict}

    @return: A dictionary mapping argument names (those declared in C{argspec})
        to values that were passed explicitly by the user.
    @rtype: L{dict} mapping L{str} to L{object}
    """
    result: Dict[str, object] = {}
    unpassed = len(argspec.args) - len(positional)
    if argspec.keywords is not None:
        kwargs = result[argspec.keywords] = {}
    if unpassed < 0:
        if argspec.varargs is None:
            raise TypeError("Too many arguments.")
        else:
            result[argspec.varargs] = positional[len(argspec.args) :]
    for name, value in zip(argspec.args, positional):
        result[name] = value
    for name, value in keyword.items():
        if name in argspec.args:
            if name in result:
                raise TypeError("Already passed.")
            result[name] = value
        elif argspec.keywords is not None:
            kwargs[name] = value
        else:
            raise TypeError("no such param")
    return result


def _passedSignature(signature, positional, keyword):
    """
    Take an L{inspect.Signature}, a tuple of positional arguments, and a dict of
    keyword arguments, and return a mapping of arguments that were actually
    passed to their passed values.

    @param signature: The signature of the function to inspect.
    @type signature: L{inspect.Signature}

    @param positional: The positional arguments that were passed.
    @type positional: L{tuple}

    @param keyword: The keyword arguments that were passed.
    @type keyword: L{dict}

    @return: A dictionary mapping argument names (those declared in
        C{signature}) to values that were passed explicitly by the user.
    @rtype: L{dict} mapping L{str} to L{object}
    """
    result = {}
    kwargs = None
    numPositional = 0
    for n, (name, param) in enumerate(signature.parameters.items()):
        if param.kind == inspect.Parameter.VAR_POSITIONAL:
            # Varargs, for example: *args
            result[name] = positional[n:]
            numPositional = len(result[name]) + 1
        elif param.kind == inspect.Parameter.VAR_KEYWORD:
            # Variable keyword args, for example: **my_kwargs
            kwargs = result[name] = {}
        elif param.kind in (
            inspect.Parameter.POSITIONAL_OR_KEYWORD,
            inspect.Parameter.POSITIONAL_ONLY,
        ):
            if n < len(positional):
                result[name] = positional[n]
                numPositional += 1
        elif param.kind == inspect.Parameter.KEYWORD_ONLY:
            if name not in keyword:
                if param.default == inspect.Parameter.empty:
                    raise TypeError(f"missing keyword arg {name}")
                else:
                    result[name] = param.default
        else:
            raise TypeError(f"'{name}' parameter is invalid kind: {param.kind}")

    if len(positional) > numPositional:
        raise TypeError("Too many arguments.")
    for name, value in keyword.items():
        if name in signature.parameters.keys():
            if name in result:
                raise TypeError("Already passed.")
            result[name] = value
        elif kwargs is not None:
            kwargs[name] = value
        else:
            raise TypeError("no such param")
    return result


def _mutuallyExclusiveArguments(
    argumentPairs: Sequence[tuple[str, str]]
) -> Callable[[Callable[_P, _R]], Callable[_P, _R]]:
    """
    Decorator which causes its decoratee to raise a L{TypeError} if two of the
    given arguments are passed at the same time.

    @param argumentPairs: pairs of argument identifiers, each pair indicating
        an argument that may not be passed in conjunction with another.

    @return: A decorator, used like so::

            @_mutuallyExclusiveArguments([["tweedledum", "tweedledee"]])
            def function(tweedledum=1, tweedledee=2):
                "Don't pass tweedledum and tweedledee at the same time."
    """

    def wrapper(wrappee: Callable[_P, _R]) -> Callable[_P, _R]:
        spec = inspect.signature(wrappee)
        _passed = _passedSignature

        @wraps(wrappee)
        def wrapped(*args: _P.args, **kwargs: _P.kwargs) -> _R:
            arguments = _passed(spec, args, kwargs)
            for this, that in argumentPairs:
                if this in arguments and that in arguments:
                    raise TypeError(
                        ("The %r and %r arguments to %s " "are mutually exclusive.")
                        % (this, that, _fullyQualifiedName(wrappee))
                    )
            return wrappee(*args, **kwargs)

        return wrapped

    return wrapper


_Tc = TypeVar("_Tc", bound=Callable[..., Any])


def deprecatedKeywordParameter(
    version: Version, name: str, replacement: Optional[str] = None
) -> Callable[[_Tc], _Tc]:
    """
    Return a decorator that marks a keyword parameter of a callable
    as deprecated. A warning will be emitted if a caller supplies
    a value for the parameter, whether the caller uses a keyword or
    positional syntax.

    @type version: L{incremental.Version}
    @param version: The version in which the parameter will be marked as
        having been deprecated.

    @type name: L{str}
    @param name: The name of the deprecated parameter.

    @type replacement: L{str}
    @param replacement: Optional text indicating what should be used in
        place of the deprecated parameter.

    @since: Twisted 21.2.0
    """

    def wrapper(wrappee: _Tc) -> _Tc:
        warningString = _getDeprecationWarningString(
            f"The {name!r} parameter to {_fullyQualifiedName(wrappee)}",
            version,
            replacement=replacement,
        )

        doc = "The {!r} parameter was deprecated in {}".format(
            name,
            getVersionString(version),
        )
        if replacement:
            doc = doc + "; " + _getReplacementString(replacement)
        doc += "."

        params = inspect.signature(wrappee).parameters
        if (
            name in params
            and params[name].kind == inspect.Parameter.POSITIONAL_OR_KEYWORD
        ):
            parameterIndex = list(params).index(name)

            def checkDeprecatedParameter(*args, **kwargs):
                if len(args) > parameterIndex or name in kwargs:
                    warn(warningString, DeprecationWarning, stacklevel=2)
                return wrappee(*args, **kwargs)

        else:

            def checkDeprecatedParameter(*args, **kwargs):
                if name in kwargs:
                    warn(warningString, DeprecationWarning, stacklevel=2)
                return wrappee(*args, **kwargs)

        decorated = cast(_Tc, wraps(wrappee)(checkDeprecatedParameter))
        _appendToDocstring(decorated, doc)
        return decorated

    return wrapper
