From bb12184551c5249d468213b4e56e315c49b7a2e3 Mon Sep 17 00:00:00 2001 From: Eugene Yurtsev Date: Tue, 8 Aug 2023 15:42:22 -0400 Subject: [PATCH] Internal code deprecation API (#8763) Proposal for an internal API to deprecate LangChain code. This PR is heavily based on: https://github.com/matplotlib/matplotlib/blob/main/lib/matplotlib/_api/deprecation.py This PR only includes deprecation functionality (no renaming etc.). Additional functionality can be added on a need basis (e.g., renaming parameters), but best to roll out as an MVP to test this out. DeprecationWarnings are ignored by default. We can change the policy for the deprecation warnings, but we'll need to make sure we're not creating noise for users due to internal code invoking deprecated functionality. --- libs/langchain/langchain/_api/__init__.py | 22 ++ libs/langchain/langchain/_api/deprecation.py | 306 ++++++++++++++++++ .../tests/unit_tests/_api/__init__.py | 0 .../tests/unit_tests/_api/test_deprecation.py | 252 +++++++++++++++ 4 files changed, 580 insertions(+) create mode 100644 libs/langchain/langchain/_api/__init__.py create mode 100644 libs/langchain/langchain/_api/deprecation.py create mode 100644 libs/langchain/tests/unit_tests/_api/__init__.py create mode 100644 libs/langchain/tests/unit_tests/_api/test_deprecation.py diff --git a/libs/langchain/langchain/_api/__init__.py b/libs/langchain/langchain/_api/__init__.py new file mode 100644 index 0000000000..168a99bcfd --- /dev/null +++ b/libs/langchain/langchain/_api/__init__.py @@ -0,0 +1,22 @@ +"""Helper functions for managing the LangChain API. + +This module is only relevant for LangChain developers, not for users. + +.. warning:: + + This module and its submodules are for internal use only. Do not use them + in your own code. We may change the API at any time with no warning. + +""" + +from .deprecation import ( + LangChainDeprecationWarning, + deprecated, + suppress_langchain_deprecation_warning, +) + +__all__ = [ + "deprecated", + "LangChainDeprecationWarning", + "suppress_langchain_deprecation_warning", +] diff --git a/libs/langchain/langchain/_api/deprecation.py b/libs/langchain/langchain/_api/deprecation.py new file mode 100644 index 0000000000..30164ab67e --- /dev/null +++ b/libs/langchain/langchain/_api/deprecation.py @@ -0,0 +1,306 @@ +"""Helper functions for deprecating parts of the LangChain API. + +This module was adapted from matplotlibs _api/deprecation.py module: + +https://github.com/matplotlib/matplotlib/blob/main/lib/matplotlib/_api/deprecation.py + +.. warning:: + + This module is for internal use only. Do not use it in your own code. + We may change the API at any time with no warning. +""" + +import contextlib +import functools +import inspect +import warnings +from typing import Any, Callable, Generator, Type, TypeVar + + +class LangChainDeprecationWarning(DeprecationWarning): + """A class for issuing deprecation warnings for LangChain users.""" + + +def _warn_deprecated( + since: str, + *, + message: str = "", + name: str = "", + alternative: str = "", + pending: bool = False, + obj_type: str = "", + addendum: str = "", + removal: str = "", +) -> None: + """Display a standardized deprecation. + + Arguments: + since : str + The release at which this API became deprecated. + message : str, optional + Override the default deprecation message. The %(since)s, + %(name)s, %(alternative)s, %(obj_type)s, %(addendum)s, + and %(removal)s format specifiers will be replaced by the + values of the respective arguments passed to this function. + name : str, optional + The name of the deprecated object. + 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 if provided. + pending : bool, optional + If True, uses a PendingDeprecationWarning instead of a + DeprecationWarning. Cannot be used together with removal. + obj_type : str, optional + The object type being deprecated. + addendum : str, optional + Additional text appended directly to the final message. + removal : str, optional + The expected removal version. With the default (an empty + string), a removal version is automatically computed from + since. Set to other Falsy values to not schedule a removal + date. Cannot be used together with pending. + """ + if pending and removal: + raise ValueError("A pending deprecation cannot have a scheduled removal") + + if not pending: + if not removal: + removal = f"in {removal}" if removal else "within ?? minor releases" + raise NotImplementedError( + f"Need to determine which default deprecation schedule to use. " + f"{removal}" + ) + else: + removal = f"in {removal}" + + if not message: + message = "" + + if obj_type: + message += f"The {obj_type} `{name}`" + else: + message += f"`{name}`" + + if pending: + message += " will be deprecated in a future version" + else: + message += f" was deprecated in LangChain {since}" + + if removal: + message += f" and will be removed {removal}" + + if alternative: + message += f". Use {alternative} instead." + + if addendum: + message += f" {addendum}" + + warning_cls = PendingDeprecationWarning if pending else LangChainDeprecationWarning + warning = warning_cls(message) + warnings.warn(warning, category=LangChainDeprecationWarning, stacklevel=2) + + +# PUBLIC API + + +T = TypeVar("T", Type, Callable) + + +def deprecated( + since: str, + *, + message: str = "", + name: str = "", + alternative: str = "", + pending: bool = False, + obj_type: str = "", + addendum: str = "", + removal: str = "", +) -> Callable[[T], T]: + """Decorator to mark a function, a class, or a property as deprecated. + + When deprecating a classmethod, a staticmethod, or a property, the + ``@deprecated`` decorator should go *under* ``@classmethod`` and + ``@staticmethod`` (i.e., `deprecated` should directly decorate the + underlying callable), but *over* ``@property``. + + When deprecating a class ``C`` intended to be used as a base class in a + multiple inheritance hierarchy, ``C`` *must* define an ``__init__`` method + (if ``C`` instead inherited its ``__init__`` from its own base class, then + ``@deprecated`` would mess up ``__init__`` inheritance when installing its + own (deprecation-emitting) ``C.__init__``). + + Parameters are the same as for `warn_deprecated`, except that *obj_type* + defaults to 'class' if decorating a class, 'attribute' if decorating a + property, and 'function' otherwise. + + Arguments: + since : str + The release at which this API became deprecated. + message : str, optional + Override the default deprecation message. The %(since)s, + %(name)s, %(alternative)s, %(obj_type)s, %(addendum)s, + and %(removal)s format specifiers will be replaced by the + values of the respective arguments passed to this function. + name : str, optional + The name of the deprecated object. + 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 if provided. + pending : bool, optional + If True, uses a PendingDeprecationWarning instead of a + DeprecationWarning. Cannot be used together with removal. + obj_type : str, optional + The object type being deprecated. + addendum : str, optional + Additional text appended directly to the final message. + removal : str, optional + The expected removal version. With the default (an empty + string), a removal version is automatically computed from + since. Set to other Falsy values to not schedule a removal + date. Cannot be used together with pending. + + Examples + -------- + + .. code-block:: python + + @deprecated('1.4.0') + def the_function_to_deprecate(): + pass + """ + + def deprecate( + obj: T, + *, + _obj_type: str = obj_type, + _name: str = name, + _message: str = message, + _alternative: str = alternative, + _pending: bool = pending, + _addendum: str = addendum, + ) -> T: + """Implementation of the decorator returned by `deprecated`.""" + if isinstance(obj, type): + if not _obj_type: + _obj_type = "class" + wrapped = obj.__init__ # type: ignore + _name = _name or obj.__name__ + old_doc = obj.__doc__ + + def finalize(wrapper: Callable[..., Any], new_doc: str) -> T: + """Finalize the deprecation of a class.""" + try: + obj.__doc__ = new_doc + except AttributeError: # Can't set on some extension objects. + pass + obj.__init__ = functools.wraps(obj.__init__)( # type: ignore[misc] + wrapper + ) + return obj + + elif isinstance(obj, property): + if not _obj_type: + _obj_type = "attribute" + wrapped = None + _name = _name or obj.fget.__name__ + old_doc = obj.__doc__ + + class _deprecated_property(type(obj)): # type: ignore + """A deprecated property.""" + + def __get__(self, instance, owner=None): # type: ignore + if instance is not None or owner is not None: + emit_warning() + return super().__get__(instance, owner) + + def __set__(self, instance, value): # type: ignore + if instance is not None: + emit_warning() + return super().__set__(instance, value) + + def __delete__(self, instance): # type: ignore + if instance is not None: + emit_warning() + return super().__delete__(instance) + + def __set_name__(self, owner, set_name): # type: ignore + nonlocal _name + if _name == "": + _name = set_name + + def finalize(_: Any, new_doc: str) -> Any: # type: ignore + """Finalize the property.""" + return _deprecated_property( + fget=obj.fget, fset=obj.fset, fdel=obj.fdel, doc=new_doc + ) + + else: + if not _obj_type: + _obj_type = "function" + wrapped = obj + _name = _name or obj.__name__ # type: ignore + old_doc = wrapped.__doc__ + + def finalize( # type: ignore + wrapper: Callable[..., Any], new_doc: str + ) -> T: + """Wrap the wrapped function using the wrapper and update the docstring. + + Args: + wrapper: The wrapper function. + new_doc: The new docstring. + + Returns: + The wrapped function. + """ + wrapper = functools.wraps(wrapped)(wrapper) + wrapper.__doc__ = new_doc + return wrapper + + def emit_warning() -> None: + """Emit the warning.""" + _warn_deprecated( + since, + message=_message, + name=_name, + alternative=_alternative, + pending=_pending, + obj_type=_obj_type, + addendum=_addendum, + removal=removal, + ) + + def warning_emitting_wrapper(*args: Any, **kwargs: Any) -> Any: + """Wrapper for the original wrapped callable that emits a warning. + + Args: + *args: The positional arguments to the function. + **kwargs: The keyword arguments to the function. + + Returns: + The return value of the function being wrapped. + """ + emit_warning() + return wrapped(*args, **kwargs) + + old_doc = inspect.cleandoc(old_doc or "").strip("\n") + + if not old_doc: + new_doc = "[*Deprecated*]" + else: + new_doc = f"[*Deprecated*] {old_doc}" + + return finalize(warning_emitting_wrapper, new_doc) + + return deprecate + + +@contextlib.contextmanager +def suppress_langchain_deprecation_warning() -> Generator[None, None, None]: + """Context manager to suppress LangChainDeprecationWarning.""" + with warnings.catch_warnings(): + warnings.simplefilter("ignore", LangChainDeprecationWarning) + yield diff --git a/libs/langchain/tests/unit_tests/_api/__init__.py b/libs/langchain/tests/unit_tests/_api/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/libs/langchain/tests/unit_tests/_api/test_deprecation.py b/libs/langchain/tests/unit_tests/_api/test_deprecation.py new file mode 100644 index 0000000000..ef16c38223 --- /dev/null +++ b/libs/langchain/tests/unit_tests/_api/test_deprecation.py @@ -0,0 +1,252 @@ +import warnings +from typing import Any, Dict + +import pytest +from pydantic import BaseModel + +from langchain._api.deprecation import _warn_deprecated, deprecated + + +@pytest.mark.parametrize( + "kwargs, expected_message", + [ + ( + { + "since": "1.0.0", + "name": "OldClass", + "alternative": "NewClass", + "pending": True, + "obj_type": "class", + }, + "The class `OldClass` will be deprecated in a future version. Use NewClass " + "instead.", + ), + ( + { + "since": "2.0.0", + "message": "This is a custom message", + "name": "FunctionA", + "alternative": "", + "pending": True, + "obj_type": "", + "addendum": "", + "removal": "", + }, + "This is a custom message", + ), + ( + { + "since": "1.5.0", + "message": "", + "name": "SomeFunction", + "alternative": "", + "pending": False, + "obj_type": "", + "addendum": "Please migrate your code.", + "removal": "2.5.0", + }, + "`SomeFunction` was deprecated in LangChain 1.5.0 and will be " + "removed in 2.5.0 Please migrate your code.", + ), + ], +) +def test_warn_deprecated(kwargs: Dict[str, Any], expected_message: str) -> None: + """Test warn deprecated.""" + with warnings.catch_warnings(record=True) as warning_list: + warnings.simplefilter("always") + + _warn_deprecated(**kwargs) + + assert len(warning_list) == 1 + warning = warning_list[0].message + assert str(warning) == expected_message + + +def test_undefined_deprecation_schedule() -> None: + """This test is expected to fail until we defined a deprecation schedule.""" + with pytest.raises(NotImplementedError): + _warn_deprecated("1.0.0", pending=False) + + +@deprecated(since="2.0.0", removal="3.0.0", pending=False) +def deprecated_function() -> str: + """original doc""" + return "This is a deprecated function." + + +class ClassWithDeprecatedMethods: + def __init__(self) -> None: + """original doc""" + pass + + @deprecated(since="2.0.0", removal="3.0.0") + def deprecated_method(self) -> str: + """original doc""" + return "This is a deprecated method." + + @classmethod + @deprecated(since="2.0.0", removal="3.0.0") + def deprecated_classmethod(cls) -> str: + """original doc""" + return "This is a deprecated classmethod." + + @staticmethod + @deprecated(since="2.0.0", removal="3.0.0") + def deprecated_staticmethod() -> str: + """original doc""" + return "This is a deprecated staticmethod." + + @property + @deprecated(since="2.0.0", removal="3.0.0") + def deprecated_property(self) -> str: + """original doc""" + return "This is a deprecated property." + + +def test_deprecated_function() -> None: + """Test deprecated function.""" + with warnings.catch_warnings(record=True) as warning_list: + warnings.simplefilter("always") + assert deprecated_function() == "This is a deprecated function." + assert len(warning_list) == 1 + warning = warning_list[0].message + assert str(warning) == ( + "The function `deprecated_function` was deprecated in LangChain 2.0.0 " + "and will be removed in 3.0.0" + ) + + assert deprecated_function.__doc__ == "[*Deprecated*] original doc" + + +def test_deprecated_method() -> None: + """Test deprecated method.""" + with warnings.catch_warnings(record=True) as warning_list: + warnings.simplefilter("always") + obj = ClassWithDeprecatedMethods() + assert obj.deprecated_method() == "This is a deprecated method." + assert len(warning_list) == 1 + warning = warning_list[0].message + assert str(warning) == ( + "The function `deprecated_method` was deprecated in " + "LangChain 2.0.0 and will be removed in 3.0.0" + ) + + assert obj.deprecated_method.__doc__ == "[*Deprecated*] original doc" + + +def test_deprecated_classmethod() -> None: + """Test deprecated classmethod.""" + with warnings.catch_warnings(record=True) as warning_list: + warnings.simplefilter("always") + ClassWithDeprecatedMethods.deprecated_classmethod() + assert len(warning_list) == 1 + warning = warning_list[0].message + assert str(warning) == ( + "The function `deprecated_classmethod` was deprecated in " + "LangChain 2.0.0 and will be removed in 3.0.0" + ) + assert ( + ClassWithDeprecatedMethods.deprecated_classmethod.__doc__ + == "[*Deprecated*] original doc" + ) + + +def test_deprecated_staticmethod() -> None: + """Test deprecated staticmethod.""" + with warnings.catch_warnings(record=True) as warning_list: + warnings.simplefilter("always") + assert ( + ClassWithDeprecatedMethods.deprecated_staticmethod() + == "This is a deprecated staticmethod." + ) + assert len(warning_list) == 1 + warning = warning_list[0].message + + assert str(warning) == ( + "The function `deprecated_staticmethod` was deprecated in " + "LangChain 2.0.0 and will be removed in 3.0.0" + ) + assert ( + ClassWithDeprecatedMethods.deprecated_staticmethod.__doc__ + == "[*Deprecated*] original doc" + ) + + +def test_deprecated_property() -> None: + """Test deprecated staticmethod.""" + with warnings.catch_warnings(record=True) as warning_list: + warnings.simplefilter("always") + + obj = ClassWithDeprecatedMethods() + assert obj.deprecated_property == "This is a deprecated property." + + assert len(warning_list) == 1 + warning = warning_list[0].message + + assert str(warning) == ( + "The function `deprecated_property` was deprecated in " + "LangChain 2.0.0 and will be removed in 3.0.0" + ) + assert ( + ClassWithDeprecatedMethods.deprecated_property.__doc__ + == "[*Deprecated*] original doc" + ) + + +def test_whole_class_deprecation() -> None: + """Test whole class deprecation.""" + + # Test whole class deprecation + @deprecated(since="2.0.0", removal="3.0.0") + class DeprecatedClass: + def __init__(self) -> None: + """original doc""" + pass + + @deprecated(since="2.0.0", removal="3.0.0") + def deprecated_method(self) -> str: + """original doc""" + return "This is a deprecated method." + + with warnings.catch_warnings(record=True) as warning_list: + warnings.simplefilter("always") + + obj = DeprecatedClass() + assert obj.deprecated_method() == "This is a deprecated method." + + assert len(warning_list) == 2 + warning = warning_list[0].message + assert str(warning) == ( + "The class `DeprecatedClass` was deprecated in " + "LangChain 2.0.0 and will be removed in 3.0.0" + ) + + warning = warning_list[1].message + assert str(warning) == ( + "The function `deprecated_method` was deprecated in " + "LangChain 2.0.0 and will be removed in 3.0.0" + ) + + +# Tests with pydantic models +class MyModel(BaseModel): + @deprecated(since="2.0.0", removal="3.0.0") + def deprecated_method(self) -> str: + """original doc""" + return "This is a deprecated method." + + +def test_deprecated_method_pydantic() -> None: + """Test deprecated method.""" + with warnings.catch_warnings(record=True) as warning_list: + warnings.simplefilter("always") + obj = MyModel() + assert obj.deprecated_method() == "This is a deprecated method." + assert len(warning_list) == 1 + warning = warning_list[0].message + assert str(warning) == ( + "The function `deprecated_method` was deprecated in " + "LangChain 2.0.0 and will be removed in 3.0.0" + ) + + assert obj.deprecated_method.__doc__ == "[*Deprecated*] original doc"