Repository: lektor/lektor
Pull request #1113 (2023-03-03 17:04:24)

Remove dependency on `deprecated` package
<!---
Remember to please check that your code passes tests locally. Test with the Makefile
commands `make test`, or if you only need to test just Python / JS `make test-python`
or `make test-js`.
--->

Here we remove our dependency on [deprecated] and re-implement our own `@deprecated` decorator (which we used to mark deprecated functions and methods).

### Issue(s) Resolved

Mostly, in a future PR (soon to be created) I want to add a couple optional parameters to the `@deprecated` decorator.

This also removes a transitive dependency on `wrapt`.

### Related Issues / Links

<!---
Are there any similar or related issues or pull requests?
Did you make a pull request to update the docs?
--->

### Description of Changes

- [ x] Wrote at least one-line docstrings (for any new functions)
- [ x] Added unit test(s) covering the changes (if testable)


<!--- Explain what you've done and why --->

<!--- Thanks for your help making Lektor better for everyone! --->

[deprecated]: https://pypi.org/project/Deprecated/


Patch:
diff --git a/lektor/imagetools.py b/lektor/imagetools.py
index 0418aa5..525ca1c 100644
--- a/lektor/imagetools.py
+++ b/lektor/imagetools.py
@@ -13,6 +13,7 @@ import exifread
 import filetype
 
 from lektor.reporter import reporter
+from lektor.utils import deprecated
 from lektor.utils import get_dependent_url
 from lektor.utils import locate_executable
 from lektor.utils import portable_popen
@@ -29,24 +30,19 @@ class ThumbnailMode(Enum):
 
     DEFAULT = "fit"
 
-    @property
+    @property  # type: ignore[misc] # https://github.com/python/mypy/issues/1362
+    @deprecated("Use ThumbnailMode.value instead", version="3.3.0")
     def label(self):
         """The mode's label as used in templates."""
-        warnings.warn(
-            "ThumbnailMode.label is deprecated. (Use ThumbnailMode.value instead.)",
-            DeprecationWarning,
-        )
         return self.value
 
     @classmethod
+    @deprecated(
+        "Use the ThumbnailMode constructor, e.g. 'ThumbnailMode(label)', instead",
+        version="3.3.0",
+    )
     def from_label(cls, label):
         """Looks up the thumbnail mode by its textual representation."""
-        warnings.warn(
-            "ThumbnailMode.from_label is deprecated. "
-            "Use the ThumbnailMode constructor, "
-            'e.g. "ThumbnailMode(label)", instead.',
-            DeprecationWarning,
-        )
         return cls(label)
 
 
diff --git a/lektor/markdown/__init__.py b/lektor/markdown/__init__.py
index 558f1e3..df466a1 100644
--- a/lektor/markdown/__init__.py
+++ b/lektor/markdown/__init__.py
@@ -6,7 +6,6 @@ from typing import Type
 from typing import TYPE_CHECKING
 from weakref import ref as weakref
 
-from deprecated import deprecated
 from markupsafe import Markup
 
 from lektor.compat import importlib_metadata as metadata
@@ -16,6 +15,7 @@ from lektor.markdown.controller import MarkdownController
 from lektor.markdown.controller import Meta
 from lektor.markdown.controller import RenderResult
 from lektor.sourceobj import SourceObject
+from lektor.utils import deprecated
 
 if TYPE_CHECKING:  # pragma: no cover
     from lektor.environment import Environment
@@ -35,12 +35,12 @@ else:  # pragma: no cover
 get_controller = ControllerCache(controller_class)
 
 
-@deprecated
+@deprecated(version="3.4.0")
 def make_markdown(env: "Environment") -> Any:  # (Environment) -> mistune.Markdown
     return get_controller(env).make_parser()
 
 
-@deprecated
+@deprecated(version="3.4.0")
 def markdown_to_html(
     text: str, record: SourceObject, field_options: FieldOptions
 ) -> RenderResult:
diff --git a/lektor/markdown/mistune0.py b/lektor/markdown/mistune0.py
index 63154de..857b0fd 100644
--- a/lektor/markdown/mistune0.py
+++ b/lektor/markdown/mistune0.py
@@ -5,12 +5,12 @@ from typing import List
 from typing import Optional
 
 import mistune  # type: ignore[import]
-from deprecated import deprecated
 
 from lektor.markdown.controller import MarkdownController
 from lektor.markdown.controller import Meta  # FIXME: move this?
 from lektor.markdown.controller import RendererHelper
 from lektor.sourceobj import SourceObject
+from lektor.utils import deprecated
 
 
 def _escape(text: str) -> str:
@@ -24,12 +24,12 @@ class ImprovedRenderer(
     lektor: ClassVar = RendererHelper()
 
     @property  # type: ignore[misc] # https://github.com/python/mypy/issues/1362
-    @deprecated("Use ImprovedRenderer.lektor.record instead.")
+    @deprecated("Use ImprovedRenderer.lektor.record instead.", version="3.4.0")
     def record(self) -> SourceObject:
         return self.lektor.record
 
     @property  # type: ignore[misc]
-    @deprecated("Use ImprovedRenderer.lektor.meta instead.")
+    @deprecated("Use ImprovedRenderer.lektor.meta instead.", version="3.4.0")
     def meta(self) -> Meta:
         return self.lektor.meta
 
diff --git a/lektor/pluginsystem.py b/lektor/pluginsystem.py
index 22058ca..abdfd11 100644
--- a/lektor/pluginsystem.py
+++ b/lektor/pluginsystem.py
@@ -209,6 +209,7 @@ class PluginController:
                 rv[plugin.id] = handler(**kw)
                 if "extra_flags" not in kw:
                     warnings.warn(
+                        # deprecated since 3.2.0
                         f"The plugin {plugin.id!r} function {funcname!r} does not "
                         "accept extra_flags. "
                         "It should be updated to accept `**extra` so that it will "
diff --git a/lektor/publisher.py b/lektor/publisher.py
index e934430..3c7e4ca 100644
--- a/lektor/publisher.py
+++ b/lektor/publisher.py
@@ -836,6 +836,7 @@ class GithubPagesPublisher(Publisher):
                     " ".join(
                         cleandoc(self._DEFAULT_BRANCH_DEPRECATION_MSG).splitlines()
                     ),
+                    # deprecated in version 3.4.0
                     category=DeprecationWarning,
                 )
                 branch = "master"
@@ -872,7 +873,7 @@ class GithubPagesPublisher(Publisher):
     Currently, by default, Lektor pushes to the 'master' branch when
     deploying to GitHub pages repositories.  In a future version of
     Lektor, the default branch will GitHub's new default, 'main'.
-    It is suggest that you explicitly set which branch to push to.
+    It is suggested that you explicitly set which branch to push to.
     """
 
     @staticmethod
diff --git a/lektor/utils.py b/lektor/utils.py
index 4eebdb1..b4d88d6 100644
--- a/lektor/utils.py
+++ b/lektor/utils.py
@@ -1,4 +1,5 @@
-# -*- coding: utf-8 -*-
+from __future__ import annotations
+
 import codecs
 import json
 import os
@@ -9,12 +10,18 @@ import sys
 import tempfile
 import unicodedata
 import uuid
+import warnings
 from contextlib import contextmanager
+from dataclasses import dataclass
 from datetime import datetime
 from functools import lru_cache
+from functools import wraps
 from pathlib import PurePosixPath
+from typing import Any
+from typing import Callable
 from typing import Hashable
 from typing import Iterable
+from typing import overload
 from typing import TypeVar
 from urllib.parse import urlsplit
 
@@ -712,3 +719,102 @@ def unique_everseen(seq: Iterable[_H]) -> Iterable[_H]:
         if val not in seen:
             seen.add(val)
             yield val
+
+
+class DeprecatedWarning(DeprecationWarning):
+    """Warning category issued by our ``deprecated`` decorator."""
+
+    def __init__(
+        self,
+        name: str,
+        reason: str | None = None,
+        version: str | None = None,
+    ):
+        self.name = name
+        self.reason = reason
+        self.version = version
+
+    def __str__(self) -> str:
+        message = f"{self.name!r} is deprecated"
+        if self.reason:
+            message += f" ({self.reason})"
+        if self.version:
+            message += f" since version {self.version}"
+        return message
+
+
+_F = TypeVar("_F", bound=Callable[..., Any])
+
+
+@dataclass
+class _Deprecate:
+    """A decorator to mark callables as deprecated."""
+
+    reason: str | None = None
+    version: str | None = None
+
+    def __call__(self, wrapped: _F) -> _F:
+        if not callable(wrapped):
+            raise TypeError("do not know how to deprecate {wrapped!r}")
+
+        message = DeprecatedWarning(wrapped.__name__, self.reason, self.version)
+
+        @wraps(wrapped)
+        def wrapper(*args: Any, **kwargs: Any) -> Any:
+            warnings.warn(message, stacklevel=2)
+            with warnings.catch_warnings():
+                # ignore any of our own custom warnings generated by wrapped callable
+                warnings.simplefilter("ignore", category=DeprecatedWarning)
+                return wrapped(*args, **kwargs)
+
+        return wrapper  # type: ignore[return-value]
+
+
+@overload
+def deprecated(
+    __wrapped: Callable[..., Any],
+    *,
+    reason: str | None = ...,
+    version: str | None = ...,
+) -> Callable[..., Any]:
+    ...
+
+
+@overload
+def deprecated(__reason: str, *, version: str | None = ...) -> _Deprecate:
+    ...
+
+
+@overload
+def deprecated(*, reason: str | None = ..., version: str | None = ...) -> _Deprecate:
+    ...
+
+
+def deprecated(*args: Any, **kwargs: Any) -> _F | _Deprecate:
+    """A decorator to mark callables or descriptors as deprecated.
+
+    This can be used to decorate functions, methods, classes, and descriptors.
+    In particular, this decorator can be applied to instances of ``property``,
+    ``functools.cached_property`` and ``werkzeug.utils.cached_property``.
+
+    When the decorated object is called (or — in the case of a descriptor — accessed), a
+    ``DeprecationWarning`` is issued.
+
+    The warning message will include the name of the decorated object, and may include
+    further information if provided from the ``reason`` and ``version`` arguments.
+
+    """
+    if len(args) > 1:
+        raise TypeError("deprecated accepts a maximum of one positional parameter")
+
+    wrapped: _F | None = None
+    if args:
+        if isinstance(args[0], str):
+            kwargs.setdefault("reason", args[0])
+        else:
+            wrapped = args[0]
+
+    deprecate = _Deprecate(**kwargs)
+    if wrapped is not None:
+        return deprecate(wrapped)
+    return deprecate
diff --git a/setup.cfg b/setup.cfg
index 04aee9e..c93af3b 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -37,7 +37,6 @@ python_requires = >=3.7
 install_requires =
     Babel
     click>=6.0
-    deprecated
     EXIFRead
     filetype>=1.0.7
     Flask


Test patch:
diff --git a/tests/test_markdown.py b/tests/test_markdown.py
index 73d96ba..5c677df 100644
--- a/tests/test_markdown.py
+++ b/tests/test_markdown.py
@@ -227,16 +227,20 @@ def improved_renderer():
 @pytest.mark.skipif(not MISTUNE_VERSION.startswith("0."), reason="not mistune0")
 @pytest.mark.usefixtures("renderer_context")
 def test_ImprovedRenderer_record(record, improved_renderer):
-    with pytest.deprecated_call() as warnings:
+    with pytest.deprecated_call(
+        match=r"Use .*Renderer\.lektor.record instead"
+    ) as warnings:
         assert improved_renderer.record is record
-    assert re.search(r"Use .*Renderer\.lektor.record instead", str(warnings[0].message))
+    assert all(w.filename == __file__ for w in warnings)
 
 
 @pytest.mark.skipif(not MISTUNE_VERSION.startswith("0."), reason="not mistune0")
 def test_ImprovedRenderer_meta(renderer_context, improved_renderer):
-    with pytest.deprecated_call() as warnings:
+    with pytest.deprecated_call(
+        match=r"Use .*Renderer\.lektor.meta instead"
+    ) as warnings:
         assert improved_renderer.meta is renderer_context.meta
-    assert re.search(r"Use .*Renderer\.lektor.meta instead", str(warnings[0].message))
+    assert all(w.filename == __file__ for w in warnings)
 
 
 @pytest.mark.parametrize(
@@ -288,15 +292,17 @@ def test_ImprovedRenderer_image(src, title, alt, expected, improved_renderer):
 
 
 def test_make_markdown(env):
-    with pytest.deprecated_call():
+    with pytest.deprecated_call() as warnings:
         md = make_markdown(env)
+    assert all(w.filename == __file__ for w in warnings)
     assert md("foo").strip() == "<p>foo</p>"
 
 
 @pytest.mark.usefixtures("context")
 def test_markdown_to_html(record, field_options):
-    with pytest.deprecated_call():
+    with pytest.deprecated_call() as warnings:
         result = markdown_to_html("goober", record, field_options)
+    assert all(w.filename == __file__ for w in warnings)
     assert result.html.rstrip() == "<p>goober</p>"
 
 
diff --git a/tests/test_utils.py b/tests/test_utils.py
index 19d998a..125f5bf 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -1,7 +1,9 @@
-# coding: utf-8
+from contextlib import contextmanager
+
 import pytest
 
 from lektor.utils import build_url
+from lektor.utils import deprecated
 from lektor.utils import is_path_child_of
 from lektor.utils import join_path
 from lektor.utils import magic_split_ext
@@ -140,3 +142,70 @@ def test_make_relative_url_relative_source_absolute_target():
 )
 def test_unique_everseen(seq, expected):
     assert tuple(unique_everseen(seq)) == expected
+
+
+@contextmanager
+def _local_deprecated_call(match=None):
+    """Like pytest.deprecated_call, but also check that all warnings
+    are attributed to this file.
+    """
+    with pytest.deprecated_call(match=match) as warnings:
+        yield warnings
+    assert all(w.filename == __file__ for w in warnings)
+
+
+@pytest.mark.parametrize(
+    "kwargs, match",
+    [
+        ({}, r"^'f' is deprecated$"),
+        ({"reason": "testing"}, r"^'f' is deprecated \(testing\)$"),
+        (
+            {"reason": "testing", "version": "1.2.3"},
+            r"^'f' is deprecated \(testing\) since version 1.2.3$",
+        ),
+    ],
+)
+def test_deprecated_function(kwargs, match):
+    @deprecated(**kwargs)
+    def f():
+        return 42
+
+    with _local_deprecated_call(match=match):
+        assert f() == 42
+
+
+def test_deprecated_method():
+    class Cls:
+        @deprecated
+        def f(self):  # pylint: disable=no-self-use
+            return 42
+
+        @deprecated
+        def g(self):
+            return self.f()
+
+    with _local_deprecated_call(match=r"^'g' is deprecated$") as warnings:
+        assert Cls().g() == 42
+    assert len(warnings) == 1
+
+
+def test_deprecated_classmethod():
+    class Cls:
+        @classmethod
+        @deprecated
+        def f(cls):
+            return 42
+
+        @classmethod
+        @deprecated
+        def g(cls):
+            return cls.f()
+
+    with _local_deprecated_call(match=r"^'g' is deprecated$") as warnings:
+        assert Cls().g() == 42
+    assert len([w.message for w in warnings]) == 1
+
+
+def test_deprecated_raises_type_error():
+    with pytest.raises(TypeError):
+        deprecated(0)