Repository: lektor/lektor
Pull request #1108 (2023-02-27 18:09:41)

Fix dependency tracking on VirtualSourceObjects
This pulls out the key bits from draft PR #1007.
<!---
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`.
--->

### Issue(s) Resolved

#### The `alt` of virtual source dependencies is not being recorded in the buildstate database

The `artifacts` table in the buildstate database tracks artifacts dependencies on two types of sources:
- source files
- VirtualSourceObjects

It identifies those sources with the string-valued column `path`.

Source files are tracked by setting `path` to the file-system path of the source file.
Currently, virtual sources are tracked by setting `path` to `VirtualSourceObject.path` — i.e. the Lektor db-path of the virtual source.

The issue is that the path is not the full identity key for a VirtualSourceObject — virtual sources vary with `alt` as well as `path`.

As a concrete example, what happens in the issues described in #959 is (e.g.), when building the German page for the first blog post (artifact = `/de/blog/first-post/index.html`), [`post.get_siblings()`][2] is called which registers a dependency on the `Siblings` virtual source object attached to the post.  Since the post has `alt="de"`, this is the virtual source that may be obtained by calling `pad.get("/blog/first-post@siblings", alt="de")`.  Currently, however, the dependency gets recorded only by its path (`/blog/first-post@siblings`).   When the builder, checking for changes that would necessitate a rebuild, goes to check the current state of the virtual source, it fetches [`pad.get("/blog/first-post@siblings")`][1] with no alt specified.  This is a different object — instead of `alt="de"` it has the default alt (`en`, or `_primary` before #958) — so it always compares as changed, forcing an unconditional rebuild.

[1]: https://github.com/lektor/lektor/blob/1999510ea5ba389ba97d8c99417bb336fc381320/lektor/builder.py#L160
[2]: https://github.com/lektor/lektor/blob/1999510ea5ba389ba97d8c99417bb336fc381320/example/templates/macros/blog.html#L30

<!--- List the issue(s) below, in the form "Fixes #1234"; one per line --->


### Related Issues / Links

Supercedes #1007
Supercedes #959
<!---
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! --->



Patch:
diff --git a/lektor/builder.py b/lektor/builder.py
index fc44e04..0922433 100644
--- a/lektor/builder.py
+++ b/lektor/builder.py
@@ -1,3 +1,5 @@
+from __future__ import annotations
+
 import hashlib
 import os
 import shutil
@@ -8,6 +10,7 @@ import tempfile
 from collections import deque
 from collections import namedtuple
 from contextlib import contextmanager
+from dataclasses import dataclass
 from itertools import chain
 
 import click
@@ -90,7 +93,6 @@ class BuildState:
     def __init__(self, builder, path_cache):
         self.builder = builder
 
-        self.named_temporaries = set()
         self.updated_artifacts = []
         self.failed_artifacts = []
         self.path_cache = path_cache
@@ -110,19 +112,6 @@ class BuildState:
         """The config for this buildstate."""
         return self.builder.pad.db.config
 
-    def __enter__(self):
-        return self
-
-    def __exit__(self, exc_type, exc_value, tb):
-        self.close()
-
-    def close(self):
-        for fn in self.named_temporaries:
-            try:
-                os.remove(fn)
-            except OSError:
-                pass
-
     def notify_failure(self, artifact, exc_info):
         """Notify about a failure.  This marks a failed artifact and stores
         a failure.
@@ -131,23 +120,6 @@ class BuildState:
         self.builder.failure_controller.store_failure(artifact.artifact_name, exc_info)
         reporter.report_failure(artifact, exc_info)
 
-    def make_named_temporary(self, identifier=None):
-        """Creates a named temporary file and returns the filename for it.
-        This can be usedful in some scenarious when building with external
-        tools.
-        """
-        tmpdir = os.path.join(self.builder.meta_path, "tmp")
-        try:
-            os.makedirs(tmpdir)
-        except OSError:
-            pass
-        fn = os.path.join(
-            dir,
-            "nt-%s-%s.tmp" % (identifier or "generic", os.urandom(20).encode("hex")),
-        )
-        self.named_temporaries.add(fn)
-        return fn
-
     def get_file_info(self, filename):
         if filename:
             return self.path_cache.get_file_info(filename)
@@ -156,13 +128,14 @@ class BuildState:
     def to_source_filename(self, filename):
         return self.path_cache.to_source_filename(filename)
 
-    def get_virtual_source_info(self, virtual_source_path):
-        virtual_source = self.pad.get(virtual_source_path)
-        if not virtual_source:
-            return VirtualSourceInfo(virtual_source_path, None, None)
-        mtime = virtual_source.get_mtime(self.path_cache)
-        checksum = virtual_source.get_checksum(self.path_cache)
-        return VirtualSourceInfo(virtual_source_path, mtime, checksum)
+    def get_virtual_source_info(self, virtual_source_path, alt=None):
+        virtual_source = self.pad.get(virtual_source_path, alt=alt)
+        if virtual_source is not None:
+            mtime = virtual_source.get_mtime(self.path_cache)
+            checksum = virtual_source.get_checksum(self.path_cache)
+        else:
+            mtime = checksum = None
+        return VirtualSourceInfo(virtual_source_path, alt, mtime, checksum)
 
     def connect_to_database(self):
         """Returns a database connection for the build state db."""
@@ -231,7 +204,8 @@ class BuildState:
         found = set()
         for path, mtime, size, checksum, is_dir in rv:
             if "@" in path:
-                yield path, VirtualSourceInfo(path, mtime, checksum)
+                vpath, alt = _unpack_virtual_source_path(path)
+                yield path, VirtualSourceInfo(vpath, alt, mtime, checksum)
             else:
                 file_info = FileInfo(
                     self.env, path, mtime, size, checksum, bool(is_dir)
@@ -374,13 +348,7 @@ class BuildState:
                 if info is None:
                     return False
 
-                if isinstance(info, VirtualSourceInfo):
-                    new_vinfo = self.get_virtual_source_info(info.path)
-                    if not info.unchanged(new_vinfo):
-                        return False
-
-                # If the file info is different, then it clearly changed.
-                elif not info.unchanged(self.get_file_info(info.filename)):
+                if info.is_changed(self):
                     return False
 
             return True
@@ -489,7 +457,18 @@ def _describe_fs_path_for_checksum(path):
     return b"\x00"
 
 
-class FileInfo:
+class _ArtifactSourceInfo:
+    """Base for classes that contain freshness data about artifact sources.
+
+    Concrete subclasses include FileInfo and VirtualSourceInfo.
+    """
+
+    def is_changed(self, build_state: BuildState) -> bool:
+        """Determine whether source has changed."""
+        raise NotImplementedError()
+
+
+class FileInfo(_ArtifactSourceInfo):
     """A file info object holds metainformation of a file so that changes
     can be detected easily.
     """
@@ -602,18 +581,53 @@ class FileInfo:
 
         return self.checksum == other.checksum
 
+    def is_changed(self, build_state: BuildState) -> bool:
+        other = build_state.get_file_info(self.filename)
+        return not self.unchanged(other)
+
 
-class VirtualSourceInfo:
-    def __init__(self, path, mtime=None, checksum=None):
-        self.path = path
-        self.mtime = mtime
-        self.checksum = checksum
+def _pack_virtual_source_path(path, alt):
+    """Pack VirtualSourceObject's path and alt into a single string.
+
+    The full identity key for a VirtualSourceObject is its ``path`` along with its ``alt``.
+    (Two VirtualSourceObjects with differing alts are not the same object.)
+
+    This functions packs the (path, alt) pair into a single string for storage
+    in the ``artifacts.path`` of the buildstate database.
+
+    Note that if alternatives are not configured for the current site, there is
+    only one alt, so we safely omit the alt from the packed path.
+    """
+    if alt is None or alt == PRIMARY_ALT:
+        return path
+    return f"{alt}@{path}"
+
+
+def _unpack_virtual_source_path(packed):
+    """Unpack VirtualSourceObject's path and alt from packed path.
+
+    This is the inverse of _pack_virtual_source_path.
+    """
+    alt, sep, path = packed.partition("@")
+    if not sep:
+        raise ValueError("A packed virtual source path must include at least one '@'")
+    if "@" not in path:
+        path, alt = packed, None
+    return path, alt
+
+
+@dataclass
+class VirtualSourceInfo(_ArtifactSourceInfo):
+    path: str
+    alt: str | None
+    mtime: int | None = None
+    checksum: str | None = None
 
     def unchanged(self, other):
         if not isinstance(other, VirtualSourceInfo):
             raise TypeError("'other' must be a VirtualSourceInfo, not %r" % other)
 
-        if self.path != other.path:
+        if (self.path, self.alt) != (other.path, other.alt):
             raise ValueError(
                 "trying to compare mismatched virtual paths: "
                 "%r.unchanged(%r)" % (self, other)
@@ -621,8 +635,9 @@ class VirtualSourceInfo:
 
         return (self.mtime, self.checksum) == (other.mtime, other.checksum)
 
-    def __repr__(self):
-        return "VirtualSourceInfo(%r, %r, %r)" % (self.path, self.mtime, self.checksum)
+    def is_changed(self, build_state: BuildState) -> bool:
+        other = build_state.get_virtual_source_info(self.path, self.alt)
+        return not self.unchanged(other)
 
 
 artifacts_row = namedtuple(
@@ -783,7 +798,7 @@ class Artifact:
                 rows.append(
                     artifacts_row(
                         artifact=self.artifact_name,
-                        source=v_source.path,
+                        source=_pack_virtual_source_path(v_source.path, v_source.alt),
                         source_mtime=mtime,
                         source_size=None,
                         source_checksum=checksum,
@@ -1179,43 +1194,44 @@ class Builder:
         correspond to known artifacts.
         """
         path_cache = PathCache(self.env)
+        build_state = self.new_build_state(path_cache=path_cache)
         with reporter.build(all and "clean" or "prune", self):
             self.env.plugin_controller.emit("before-prune", builder=self, all=all)
-            with self.new_build_state(path_cache=path_cache) as build_state:
-                for aft in build_state.iter_unreferenced_artifacts(all=all):
-                    reporter.report_pruned_artifact(aft)
-                    filename = build_state.get_destination_filename(aft)
-                    prune_file_and_folder(filename, self.destination_path)
-                    build_state.remove_artifact(aft)
-                build_state.prune_source_infos()
 
+            for aft in build_state.iter_unreferenced_artifacts(all=all):
+                reporter.report_pruned_artifact(aft)
+                filename = build_state.get_destination_filename(aft)
+                prune_file_and_folder(filename, self.destination_path)
+                build_state.remove_artifact(aft)
+
+            build_state.prune_source_infos()
             if all:
                 build_state.vacuum()
             self.env.plugin_controller.emit("after-prune", builder=self, all=all)
 
     def build(self, source, path_cache=None):
         """Given a source object, builds it."""
-        with self.new_build_state(path_cache=path_cache) as build_state:
-            with reporter.process_source(source):
-                prog = self.get_build_program(source, build_state)
-                self.env.plugin_controller.emit(
-                    "before-build",
-                    builder=self,
-                    build_state=build_state,
-                    source=source,
-                    prog=prog,
-                )
-                prog.build()
-                if build_state.updated_artifacts:
-                    self.update_source_info(prog, build_state)
-                self.env.plugin_controller.emit(
-                    "after-build",
-                    builder=self,
-                    build_state=build_state,
-                    source=source,
-                    prog=prog,
-                )
-                return prog, build_state
+        build_state = self.new_build_state(path_cache=path_cache)
+        with reporter.process_source(source):
+            prog = self.get_build_program(source, build_state)
+            self.env.plugin_controller.emit(
+                "before-build",
+                builder=self,
+                build_state=build_state,
+                source=source,
+                prog=prog,
+            )
+            prog.build()
+            if build_state.updated_artifacts:
+                self.update_source_info(prog, build_state)
+            self.env.plugin_controller.emit(
+                "after-build",
+                builder=self,
+                build_state=build_state,
+                source=source,
+                prog=prog,
+            )
+            return prog, build_state
 
     def get_initial_build_queue(self):
         """Returns the initial build queue as deque."""
@@ -1253,19 +1269,19 @@ class Builder:
         """Fast way to update all source infos without having to build
         everything.
         """
+        build_state = self.new_build_state()
         # We keep a dummy connection here that does not do anything which
         # helps us with the WAL handling.  See #144
         con = self.connect_to_database()
         try:
             with reporter.build("source info update", self):
-                with self.new_build_state() as build_state:
-                    to_build = self.get_initial_build_queue()
-                    while to_build:
-                        source = to_build.popleft()
-                        with reporter.process_source(source):
-                            prog = self.get_build_program(source, build_state)
-                            self.update_source_info(prog, build_state)
-                        self.extend_build_queue(to_build, prog)
+                to_build = self.get_initial_build_queue()
+                while to_build:
+                    source = to_build.popleft()
+                    with reporter.process_source(source):
+                        prog = self.get_build_program(source, build_state)
+                        self.update_source_info(prog, build_state)
+                    self.extend_build_queue(to_build, prog)
                 build_state.prune_source_infos()
         finally:
             con.close()
diff --git a/lektor/db.py b/lektor/db.py
index bcf3e1a..6ecc0b3 100644
--- a/lektor/db.py
+++ b/lektor/db.py
@@ -37,6 +37,7 @@ from lektor.sourceobj import SourceObject
 from lektor.sourceobj import VirtualSourceObject
 from lektor.utils import cleanup_path
 from lektor.utils import cleanup_url_path
+from lektor.utils import deprecated
 from lektor.utils import fs_enc
 from lektor.utils import locate_executable
 from lektor.utils import make_relative_url
@@ -366,6 +367,7 @@ class Record(SourceObject):
         return self.datamodel.pagination_config.get_pagination_controller(self)
 
     @cached_property
+    @deprecated(version="3.4.0", stacklevel=2)
     def contents(self):
         return FileContents(self.source_filename)
 
@@ -754,6 +756,7 @@ class Attachment(Record):
         )
 
     @cached_property
+    @deprecated(version="3.4.0", stacklevel=2)
     def contents(self):
         return FileContents(self.attachment_filename)
 
diff --git a/lektor/filecontents.py b/lektor/filecontents.py
index b74d975..3a56194 100644
--- a/lektor/filecontents.py
+++ b/lektor/filecontents.py
@@ -4,8 +4,11 @@ import hashlib
 import mimetypes
 import os
 
+from lektor.utils import deprecated
+
 
 class FileContents:
+    @deprecated(name="FileContents", version="3.4.0")
     def __init__(self, filename):
         self.filename = filename
         self._md5 = None
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..c7ddc92 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,128 @@ 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."""
+
+    name: str | None = None
+    reason: str | None = None
+    version: str | None = None
+    stacklevel: int = 1
+
+    def __call__(self, wrapped: _F) -> _F:
+        if not callable(wrapped):
+            raise TypeError("do not know how to deprecate {wrapped!r}")
+
+        name = self.name or wrapped.__name__
+        message = DeprecatedWarning(name, self.reason, self.version)
+
+        @wraps(wrapped)
+        def wrapper(*args: Any, **kwargs: Any) -> Any:
+            warnings.warn(message, stacklevel=self.stacklevel + 1)
+            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],
+    *,
+    name: str | None = ...,
+    reason: str | None = ...,
+    version: str | None = ...,
+    stacklevel: int = ...,
+) -> Callable[..., Any]:
+    ...
+
+
+@overload
+def deprecated(
+    __reason: str,
+    *,
+    name: str | None = ...,
+    version: str | None = ...,
+    stacklevel: int = ...,
+) -> _Deprecate:
+    ...
+
+
+@overload
+def deprecated(
+    *,
+    name: str | None = ...,
+    reason: str | None = ...,
+    version: str | None = ...,
+    stacklevel: int = ...,
+) -> _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.
+
+    The ``name`` argument may be used to specify an alternative name to use when
+    generating the warning message. By default, the ``__name__`` attribute of the
+    decorated object is used.
+
+    The ``stacklevel`` argument controls which call in the call stack the warning
+    is attributed to. The default value, ``stacklevel=1`` means the warning is
+    reported for the immediate caller of the decorated object.  Higher values
+    attribute the warning callers further back in the stack.
+
+    """
+    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/pyproject.toml b/pyproject.toml
index fecc74b..93097e6 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -41,6 +41,7 @@ paths = [
 exclude_lines = [
     "pragma: no cover",
     '^\s*\.\.\.\s*$',
+    '^\s*raise\s+NotImplementedError\(\)\s*$',
 ]
 
 ################################################################
diff --git a/setup.cfg b/setup.cfg
index 4bb89d7..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
@@ -52,13 +51,13 @@ install_requires =
     python-slugify
     pytz
     requests
-    setuptools
     watchdog
     Werkzeug>=2.1.0,<3
 
 [options.extras_require]
 ipython =
     ipython
+    traitlets
 
 [options.entry_points]
 console_scripts = 
diff --git a/tox.ini b/tox.ini
index 91b8dcd..081a2a8 100644
--- a/tox.ini
+++ b/tox.ini
@@ -28,6 +28,25 @@ depends =
     py{37,38,39,310,311}{,-mistune0,-noutils}: cover-clean
     cover-report: py{37,38,39,310,311}{,-mistune0,-noutils}
 
+# XXX: I've been experiencing sporadic failures when running tox in parallel mode.
+# The crux of the error messages when this happens appears to be something like:
+#
+#     WARNING: Skipping page https://pypi.org/simple/coverage/ because the GET request
+#         got Content-Type: Unknown. The only supported Content-Types are
+#         application/vnd.pypi.simple.v1+json, application/vnd.pypi.simple.v1+html, and
+#         text/html
+#
+#     ERROR: Could not find a version that satisfies the requirement
+#         coverage[toml] (from versions: none)
+#
+# It may be this issue: https://github.com/pypa/pip/issues/11340
+#
+# Setting download=true — which tells tox to upgrade pip to the latest version —
+# seems to fix the issue for me.
+#
+# Hopefully, at some point this can be removed.
+download = true
+
 [testenv:py{37,38,39,310,311}-noutils]
 # To test in environment without external utitilities like imagemagick and git installed,
 # break PATH in noutils environment(s).
@@ -36,6 +55,7 @@ commands =
     env PATH="{env_bin_dir}" coverage run -m pytest {posargs:tests -ra}
 
 [testenv:lint]
+use_develop = true
 deps =
     pylint==2.15.8
     pytest>=6


Test patch:
diff --git a/tests/test_builder.py b/tests/test_builder.py
index 6526839..cccaec7 100644
--- a/tests/test_builder.py
+++ b/tests/test_builder.py
@@ -3,6 +3,8 @@ from pathlib import Path
 import pytest
 from markers import imagemagick
 
+from lektor.reporter import NullReporter
+
 
 def get_child_sources(prog):
     return sorted(list(prog.iter_child_sources()), key=lambda x: x["_id"])
@@ -354,3 +356,35 @@ def test_prune_all(builder):
     pad.cache.flush()
     builder.prune(all=True)
     assert not Path(artifact.dst_filename).is_file()
+
+
+class AssertBuildsNothingReporter(NullReporter):
+    """Reporter to collect source objects which are built during a build cycle."""
+
+    def __init__(self):
+        super().__init__(env=None)
+
+    def start_artifact_build(self, is_current):
+        assert is_current
+
+
+def test_second_build_all_builds_nothing(scratch_builder, scratch_project_data):
+    # This excercises a bug having to do with not properly tracking the alt
+    # of virtual source object dependencies.  See #1108 (and #1007, #959).
+
+    # The project should have multiple alts configured. (Scratch_project does.)
+
+    # Add child pages to scratch_project
+    for child in "child1", "child2":
+        child_lr = scratch_project_data / "content" / child / "contents.lr"
+        child_lr.parent.mkdir()
+        child_lr.write_text("_template: child.html\n")
+    # Reference siblings
+    scratch_project_data.joinpath("templates/child.html").write_text(
+        "{{ this.get_siblings() }}"
+    )
+
+    scratch_builder.build_all()
+
+    with AssertBuildsNothingReporter():
+        scratch_builder.build_all()
diff --git a/tests/test_db.py b/tests/test_db.py
index fc52502..716cad8 100644
--- a/tests/test_db.py
+++ b/tests/test_db.py
@@ -12,6 +12,7 @@ from lektor.db import get_alts
 from lektor.db import Image
 from lektor.db import Query
 from lektor.db import Video
+from lektor.filecontents import FileContents
 
 
 def test_root(pad):
@@ -522,3 +523,16 @@ def test_Page_url_path_raise_error_if_paginated_and_dotted(scratch_pad):
 def test_Attachment_url_path_is_for_primary_alt(scratch_pad, alt):
     attachment = scratch_pad.get("/test.txt")
     assert attachment.url_path == "/en/test.txt"
+
+
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/",  # Page
+        "hello.txt",  # Attachment
+    ],
+)
+def test_Record_contents_is_deprecated(pad, path):
+    with pytest.deprecated_call(match=r"contents") as warnings:
+        assert isinstance(pad.get(path).contents, FileContents)
+    assert all(w.filename == __file__ for w in warnings)
diff --git a/tests/test_filecontents.py b/tests/test_filecontents.py
new file mode 100644
index 0000000..35c3608
--- /dev/null
+++ b/tests/test_filecontents.py
@@ -0,0 +1,13 @@
+import pytest
+
+from lektor.filecontents import FileContents
+
+
+def test_FileContents_is_deprecated():
+    with pytest.deprecated_call(match=r"FileContents") as warnings:
+        FileContents(__file__)
+    assert warnings[0].filename == __file__
+
+
+def test_FileContents_is_a_type():
+    assert isinstance(FileContents, type)
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..96b668e 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -1,7 +1,11 @@
-# coding: utf-8
+import warnings
+from contextlib import contextmanager
+from itertools import islice
+
 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 +144,90 @@ 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$",
+        ),
+        ({"name": "oldfunc"}, r"^'oldfunc' is deprecated$"),
+    ],
+)
+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)
+
+
+def test_deprecated_stacklevel():
+    @deprecated(stacklevel=2)
+    def f():
+        return 42
+
+    def g():
+        return f()
+
+    with _local_deprecated_call(match=r"^'f' is deprecated$") as warnings:
+        assert g() == 42
+    assert "assert g() == 42" in _warning_line(warnings[0])
+
+
+def _warning_line(warning: warnings.WarningMessage) -> str:
+    """Get the text of the line for which warning was issued."""
+    with open(warning.filename, "r", encoding="utf-8") as fp:
+        return next(islice(fp, warning.lineno - 1, None), None)