Repository: lektor/lektor
Pull request #1110 (2023-02-28 23:31:41)

Remove dependency on pytz
<!---
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`.
--->
Since the introduction of <del>PEP 614</del> PEP 615 (the `zoneinfo` module in the standard library, available since python 3.9), the future, apparently, is to transition away from `pytz` to `zoneinfo` for timezone support.

Babel, since 2.12, will now work with either `pytz` or `zoneinfo`, and no longer requires `pytz` on pythons >= 3.9.

This change also fixes some hokey-ness in the previous implementation of DateTimeType.

### Related Issues / Links

See #1109.

<!---
Are there any similar or related issues or pull requests?
Did you make a pull request to update the docs?
--->

### Description of Changes

- [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! --->


dairiki: Hmmph.  [Apparently](https://docs.python.org/3/library/zoneinfo.html#data-sources)

> Some systems, including notably Windows systems, do not have an IANA database available, and so for projects targeting cross-platform compatibility that require time zone data, it is recommended to declare a dependency on tzdata.

I've added `pytz` back to our dependency list, but only on Windows.  Perhaps, for uniformity, we need to keep it for all platforms?  OTOH, it's not the end of the world if a user has to manually install `pytz` if their system doesn't provide a suitable timezone database and they want to use named timezones in their `datetime` fields.
CAM-Gerlach: Hmm; is there a reason to not just add a dependency on [`tzdata`](https://pypi.org/project/tzdata/) instead, as the message indicates? That just ships the time zone db that `zoneinfo` needs rather than all of `pytz`.
dairiki: > Hmm; is there a reason to not just add a dependency on [`tzdata`](https://pypi.org/project/tzdata/) instead, as the message indicates?

Because I can't read.  Thank you.

Patch:
diff --git a/lektor/assets.py b/lektor/assets.py
index 099fca6..346fc08 100644
--- a/lektor/assets.py
+++ b/lektor/assets.py
@@ -12,7 +12,7 @@ def get_asset(pad, filename, parent=None):
         return None
 
     try:
-        stat_obj = os.stat(os.path.join(parent.source_filename, filename))
+        stat_obj = os.stat(os.path.join(parent._source_filename, filename))
     except OSError:
         return None
     if stat.S_ISDIR(stat_obj.st_mode):
@@ -24,11 +24,7 @@ def get_asset(pad, filename, parent=None):
 
 
 class Asset(SourceObject):
-    # Source specific overrides.  The source_filename to none removes
-    # the inherited descriptor.
     source_classification = "asset"
-    source_filename = None
-
     artifact_extension = ""
 
     def __init__(self, pad, name, path=None, parent=None):
@@ -36,12 +32,15 @@ class Asset(SourceObject):
         if parent is not None:
             if path is None:
                 path = name
-            path = os.path.join(parent.source_filename, path)
-        self.source_filename = path
+            path = os.path.join(parent._source_filename, path)
+        self._source_filename = path
 
         self.name = name
         self.parent = parent
 
+    def iter_source_filenames(self):
+        yield self._source_filename
+
     @property
     def url_name(self):
         name = self.name
@@ -99,7 +98,7 @@ class Directory(Asset):
     @property
     def children(self):
         try:
-            files = os.listdir(self.source_filename)
+            files = os.listdir(self._source_filename)
         except OSError:
             return
 
diff --git a/lektor/builder.py b/lektor/builder.py
index 854dc1f..1a1e058 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
@@ -125,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."""
@@ -200,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)
@@ -343,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
@@ -458,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.
     """
@@ -571,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)
+
+
+def _pack_virtual_source_path(path, alt):
+    """Pack VirtualSourceObject's path and alt into a single string.
 
-class VirtualSourceInfo:
-    def __init__(self, path, mtime=None, checksum=None):
-        self.path = path
-        self.mtime = mtime
-        self.checksum = checksum
+    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)
@@ -590,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(
@@ -752,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,
@@ -945,7 +991,7 @@ class Artifact:
         if exc_info is None:
             self._memorize_dependencies(
                 ctx.referenced_dependencies,
-                ctx.referenced_virtual_dependencies.values(),
+                ctx.referenced_virtual_dependencies,
             )
             self._commit()
             return
@@ -961,7 +1007,7 @@ class Artifact:
         # use a new database connection that immediately commits.
         self._memorize_dependencies(
             ctx.referenced_dependencies,
-            ctx.referenced_virtual_dependencies.values(),
+            ctx.referenced_virtual_dependencies,
             for_failure=True,
         )
 
diff --git a/lektor/cli.py b/lektor/cli.py
index 0ea1254..432f5d7 100644
--- a/lektor/cli.py
+++ b/lektor/cli.py
@@ -378,6 +378,12 @@ def server_cmd(
     is_flag=True,
     help="Print the path to the default output path.",
 )
+@click.option(
+    "package_cache_path",
+    "--package-cache",
+    is_flag=True,
+    help="Print the path to the package cache.",
+)
 @pass_context
 def project_info_cmd(ctx, as_json, **opts):
     """Prints out information about the project.  This is particular
@@ -386,20 +392,21 @@ def project_info_cmd(ctx, as_json, **opts):
     to the default output folder).
     """
     project = ctx.get_project()
+    json_data = project.to_json()
     if as_json:
-        echo_json(project.to_json())
+        echo_json(json_data)
         return
 
     ops = [k for k, v in opts.items() if v]
     if ops:
-        data = project.to_json()
         for op in ops:
-            click.echo(data.get(op, ""))
+            click.echo(json_data.get(op, ""))
     else:
-        click.echo("Name: %s" % project.name)
-        click.echo("File: %s" % project.project_file)
-        click.echo("Tree: %s" % project.tree)
-        click.echo("Output: %s" % project.get_output_path())
+        click.echo("Name: %s" % json_data["name"])
+        click.echo("File: %s" % json_data["project_file"])
+        click.echo("Tree: %s" % json_data["tree"])
+        click.echo("Output: %s" % json_data["default_output_path"])
+        click.echo("Package Cache: %s" % json_data["package_cache_path"])
 
 
 @cli.command(
diff --git a/lektor/context.py b/lektor/context.py
index 276f753..903ce87 100644
--- a/lektor/context.py
+++ b/lektor/context.py
@@ -93,7 +93,7 @@ class Context:
 
         # Processing information
         self.referenced_dependencies = set()
-        self.referenced_virtual_dependencies = {}
+        self.referenced_virtual_dependencies = set()
         self.sub_artifacts = []
 
         self.flow_block_render_stack = []
@@ -241,8 +241,7 @@ class Context:
 
     def record_virtual_dependency(self, virtual_source):
         """Records a dependency from processing."""
-        path = virtual_source.path
-        self.referenced_virtual_dependencies[path] = virtual_source
+        self.referenced_virtual_dependencies.add(virtual_source)
         for coll in self._dependency_collectors:
             coll(virtual_source)
 
diff --git a/lektor/db.py b/lektor/db.py
index bcf3e1a..7997a7e 100644
--- a/lektor/db.py
+++ b/lektor/db.py
@@ -33,10 +33,11 @@ from lektor.imagetools import get_image_info
 from lektor.imagetools import make_image_thumbnail
 from lektor.imagetools import read_exif
 from lektor.imagetools import ThumbnailMode
-from lektor.sourceobj import SourceObject
+from lektor.sourceobj import DBSourceObject
 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
@@ -302,12 +303,12 @@ class _RecordQueryProxy:
 F = _RecordQueryProxy()
 
 
-class Record(SourceObject):
+class Record(DBSourceObject):
     source_classification = "record"
     supports_pagination = False
 
     def __init__(self, pad, data, page_num=None):
-        SourceObject.__init__(self, pad)
+        super().__init__(pad)
         self._data = data
         self._bound_data = {}
         if page_num is not None and not self.supports_pagination:
@@ -332,7 +333,7 @@ class Record(SourceObject):
     @property
     def alt(self):
         """Returns the alt of this source object."""
-        return self["_alt"]
+        return self._data["_alt"]
 
     @property
     def is_hidden(self):
@@ -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)
 
@@ -436,7 +438,7 @@ class Record(SourceObject):
 
     @property
     def path(self):
-        return self["_path"]
+        return self._data["_path"]
 
     def get_sort_key(self, fields):
         """Returns a sort key for the given field specifications specific
@@ -470,21 +472,11 @@ class Record(SourceObject):
         self._bound_data[name] = rv
         return rv
 
-    def __eq__(self, other):
-        if self is other:
-            return True
-        if self.__class__ != other.__class__:
-            return False
-        return self["_path"] == other["_path"]
-
-    def __hash__(self):
-        return hash(self.path)
-
     def __repr__(self):
         return "<%s model=%r path=%r%s%s>" % (
             self.__class__.__name__,
-            self["_model"],
-            self["_path"],
+            self._data["_model"],
+            self._data["_path"],
             self.alt != PRIMARY_ALT and " alt=%r" % self.alt or "",
             self.page_num is not None and " page_num=%r" % self.page_num or "",
         )
@@ -543,7 +535,7 @@ class Page(Record):
 
     @cached_property
     def path(self):
-        rv = self["_path"]
+        rv = self._data["_path"]
         if self.page_num is not None:
             rv = "%s@%s" % (rv, self.page_num)
         return rv
@@ -553,21 +545,16 @@ class Page(Record):
         if self.page_num is None:
             return self
         return self.pad.get(
-            self["_path"], persist=self.pad.cache.is_persistent(self), alt=self.alt
+            self._data["_path"],
+            persist=self.pad.cache.is_persistent(self),
+            alt=self.alt,
         )
 
-    @property
-    def source_filename(self):
-        if self.alt != PRIMARY_ALT:
-            return os.path.join(
-                self.pad.db.to_fs_path(self["_path"]), "contents+%s.lr" % self.alt
-            )
-        return os.path.join(self.pad.db.to_fs_path(self["_path"]), "contents.lr")
-
     def iter_source_filenames(self):
-        yield self.source_filename
+        fs_path = self.pad.db.to_fs_path(self._data["_path"])
         if self.alt != PRIMARY_ALT:
-            yield os.path.join(self.pad.db.to_fs_path(self["_path"]), "contents.lr")
+            yield os.path.join(fs_path, f"contents+{self.alt}.lr")
+        yield os.path.join(fs_path, "contents.lr")
 
     @property
     def url_path(self):
@@ -663,12 +650,12 @@ class Page(Record):
         repl_query = self.datamodel.get_child_replacements(self)
         if repl_query is not None:
             return repl_query.include_undiscoverable(False)
-        return Query(path=self["_path"], pad=self.pad, alt=self.alt)
+        return Query(path=self._data["_path"], pad=self.pad, alt=self.alt)
 
     @property
     def attachments(self):
         """Returns a query for the attachments of this record."""
-        return AttachmentsQuery(path=self["_path"], pad=self.pad, alt=self.alt)
+        return AttachmentsQuery(path=self._data["_path"], pad=self.pad, alt=self.alt)
 
     def has_prev(self):
         return self.get_siblings().prev_page is not None
@@ -721,14 +708,6 @@ class Attachment(Record):
 
     is_attachment = True
 
-    @property
-    def source_filename(self):
-        if self.alt != PRIMARY_ALT:
-            suffix = "+%s.lr" % self.alt
-        else:
-            suffix = ".lr"
-        return self.pad.db.to_fs_path(self["_path"]) + suffix
-
     def _is_considered_hidden(self):
         # Attachments are only considered hidden if they have been
         # configured as such.  This means that even if a record itself is
@@ -744,7 +723,7 @@ class Attachment(Record):
 
     @property
     def attachment_filename(self):
-        return self.pad.db.to_fs_path(self["_path"])
+        return self.pad.db.to_fs_path(self._data["_path"])
 
     @property
     def parent(self):
@@ -754,6 +733,7 @@ class Attachment(Record):
         )
 
     @cached_property
+    @deprecated(version="3.4.0", stacklevel=2)
     def contents(self):
         return FileContents(self.attachment_filename)
 
@@ -761,10 +741,11 @@ class Attachment(Record):
         return self["_id"]
 
     def iter_source_filenames(self):
-        yield self.source_filename
+        attachment_filename = self.attachment_filename
         if self.alt != PRIMARY_ALT:
-            yield self.pad.db.to_fs_path(self["_path"]) + ".lr"
-        yield self.attachment_filename
+            yield f"{attachment_filename}+{self.alt}.lr"
+        yield f"{attachment_filename}.lr"
+        yield attachment_filename
 
     @property
     def url_path(self):
@@ -1513,8 +1494,8 @@ class Database:
                 else:
                     affects_url = True
                 ctx.record_dependency(filename, affects_url=affects_url)
-            for virtual_source in record.iter_virtual_sources():
-                ctx.record_virtual_dependency(virtual_source)
+            if isinstance(record, VirtualSourceObject):
+                ctx.record_virtual_dependency(record)
             if getattr(record, "datamodel", None) and record.datamodel.filename:
                 ctx.record_dependency(record.datamodel.filename)
                 for dep_model in self.iter_dependent_models(record.datamodel):
@@ -1733,7 +1714,7 @@ class Pad:
         if pieces[0].isdigit():
             if len(pieces) == 1:
                 return self.get(
-                    record["_path"], alt=record.alt, page_num=int(pieces[0])
+                    record._data["_path"], alt=record.alt, page_num=int(pieces[0])
                 )
             return None
 
diff --git a/lektor/devcli.py b/lektor/devcli.py
index 3243518..90896a6 100644
--- a/lektor/devcli.py
+++ b/lektor/devcli.py
@@ -7,9 +7,6 @@ import click
 from lektor.cli_utils import AliasedGroup
 from lektor.cli_utils import extraflag
 from lektor.cli_utils import pass_context
-from lektor.packages import get_package_info
-from lektor.packages import publish_package
-from lektor.packages import register_package
 
 try:
     from IPython import embed
@@ -18,18 +15,6 @@ except ImportError:
     pass  # fallback to normal Python InteractiveConsole
 
 
-def ensure_plugin():
-    here = os.getcwd()
-    if not os.path.isfile(os.path.join(here, "setup.py")):
-        raise click.UsageError("This command must be run in a Lektor plugin folder")
-    info = get_package_info(here)
-    if not info["name"].lower().startswith("lektor-"):
-        raise click.UsageError(
-            "Python package is misnamed. Needs to start with lektor-"
-        )
-    return info
-
-
 @click.group(cls=AliasedGroup, short_help="Development commands.")
 def cli():
     """Development commands for Lektor.
@@ -92,23 +77,6 @@ def shell_cmd(ctx, extra_flags):
         code.interact(banner=banner, local=ns)
 
 
-@cli.command("publish-plugin", short_help="Publish a plugin to PyPI.")
-def publish_plugin_cmd():
-    """Publishes the current version of the plugin in the current folder.
-
-    This generally requires that your setup.py has at least the bare minimum
-    configuration for valid publishing to PyPI.
-    """
-    info = ensure_plugin()
-    for key in "author", "author_email", "license", "url":
-        if not info[key]:
-            raise click.UsageError(
-                "Cannot publish plugin without setting " '"%s" in setup.py.' % key
-            )
-    register_package(info["path"])
-    publish_package(info["path"])
-
-
 @cli.command("new-plugin", short_help="Creates a new plugin")
 @click.option("--path", type=click.Path(), help="The destination path")
 @click.argument("plugin_name", required=False)
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/packages.py b/lektor/packages.py
index 7662c6d..93d6ece 100644
--- a/lektor/packages.py
+++ b/lektor/packages.py
@@ -1,19 +1,32 @@
-import errno
+from __future__ import annotations
+
+import hashlib
 import os
 import shutil
 import site
+import subprocess
 import sys
-import tempfile
-from subprocess import PIPE
+import sysconfig
+from pathlib import Path
+from typing import Any
+from typing import Dict
+from typing import Iterable
+from typing import Iterator
+from typing import Optional
+from typing import Set
+from typing import Sized
+from typing import TYPE_CHECKING
+from venv import EnvBuilder
 
 import click
 import requests
 
-from lektor.utils import portable_popen
-
 
-class PackageException(Exception):
-    pass
+if TYPE_CHECKING:
+    from _typeshed import StrPath
+    from lektor.environment import Environment  # circ dependency
+else:
+    StrPath = object
 
 
 def _get_package_version_from_project(cfg, name):
@@ -25,7 +38,7 @@ def _get_package_version_from_project(cfg, name):
 
 
 def add_package_to_project(project, req):
-    """Given a pacakge requirement this returns the information about this
+    """Given a package requirement this returns the information about this
     plugin.
     """
     if "@" in req:
@@ -74,251 +87,205 @@ def remove_package_from_project(project, name):
     return None
 
 
-def download_and_install_package(
-    package_root, package=None, version=None, requirements_file=None
-):
-    """This downloads and installs a specific version of a package."""
-    # XXX: windows
-    env = dict(os.environ)
-
-    args = [
-        sys.executable,
-        "-m",
-        "pip",
-        "install",
-        "--target",
-        package_root,
-    ]
-
-    if package is not None:
-        args.append("%s%s%s" % (package, version and "==" or "", version or ""))
-    if requirements_file is not None:
-        args.extend(("-r", requirements_file))
-
-    rv = portable_popen(args, env=env).wait()
-    if rv != 0:
-        raise RuntimeError("Failed to install dependency package.")
-
-
-def install_local_package(package_root, path):
-    """This installs a local dependency of a package."""
-    # XXX: windows
-    env = dict(os.environ)
-    env["PYTHONPATH"] = package_root
-
-    # Step 1: generate egg info and link us into the target folder.
-    rv = portable_popen(
-        [
-            sys.executable,
-            "-m",
-            "pip",
-            "install",
-            "--editable",
-            path,
-            "--install-option=--install-dir=%s" % package_root,
-            "--no-deps",
-        ],
-        env=env,
-    ).wait()
-    if rv != 0:
-        raise RuntimeError("Failed to install local package")
-
-    # Step 2: generate the egg info into a temp folder to find the
-    # requirements.
-    tmp = tempfile.mkdtemp()
-    try:
-        rv = portable_popen(
-            [
-                sys.executable,
-                "setup.py",
-                "--quiet",
-                "egg_info",
-                "--quiet",
-                "--egg-base",
-                tmp,
-            ],
-            cwd=path,
-        ).wait()
-        dirs = os.listdir(tmp)
-        if rv != 0 or len(dirs) != 1:
-            raise RuntimeError("Failed to create egg info for local package.")
-        requires = os.path.join(tmp, dirs[0], "requires.txt")
-
-        # We have dependencies, install them!
-        if os.path.isfile(requires):
-            download_and_install_package(package_root, requirements_file=requires)
-    finally:
-        shutil.rmtree(tmp)
-
-
-def get_package_info(path):
-    """Returns the name of a package at a path."""
-    rv = (
-        portable_popen(
-            [
-                sys.executable,
-                "setup.py",
-                "--quiet",
-                "--name",
-                "--author",
-                "--author-email",
-                "--license",
-                "--url",
-            ],
-            cwd=path,
-            stdout=PIPE,
-        )
-        .communicate()[0]
-        .splitlines()
-    )
-
-    def _process(value):
-        value = value.strip()
-        if value == "UNKNOWN":
-            return None
-        return value.decode("utf-8", "replace")
-
-    return {
-        "name": _process(rv[0]),
-        "author": _process(rv[1]),
-        "author_email": _process(rv[2]),
-        "license": _process(rv[3]),
-        "url": _process(rv[4]),
-        "path": path,
-    }
-
-
-def register_package(path):
-    """Registers the plugin at the given path."""
-    portable_popen([sys.executable, "setup.py", "register"], cwd=path).wait()
-
-
-def publish_package(path):
-    """Registers the plugin at the given path."""
-    portable_popen(
-        [sys.executable, "setup.py", "sdist", "bdist_wheel", "upload"], cwd=path
-    ).wait()
-
-
-def load_manifest(filename):
-    rv = {}
-    try:
-        with open(filename, encoding="utf-8") as f:
-            for line in f:
-                if line[:1] == "@":
-                    rv[line.strip()] = None
-                    continue
-                line = line.strip().split("=", 1)
-                if len(line) == 2:
-                    key = line[0].strip()
-                    value = line[1].strip()
-                    rv[key] = value
-    except IOError as e:
-        if e.errno != errno.ENOENT:
-            raise
-    return rv
-
-
-def write_manifest(filename, packages):
-    with open(filename, "w", encoding="utf-8") as f:
-        for package, version in sorted(packages.items()):
-            if package[:1] == "@":
-                f.write("%s\n" % package)
-            else:
-                f.write("%s=%s\n" % (package, version))
-
-
-def list_local_packages(path):
-    """Lists all local packages below a path that could be installed."""
-    rv = []
-    try:
-        for filename in os.listdir(path):
-            if os.path.isfile(os.path.join(path, filename, "setup.py")):
-                rv.append("@" + filename)
-    except OSError:
-        pass
-    return rv
-
-
-def update_cache(package_root, remote_packages, local_package_path, refresh=False):
-    """Updates the package cache at package_root for the given dictionary
-    of packages as well as packages in the given local package path.
+class VirtualEnv:
+    """A helper for manipulating our private package cache virtual environment.
+
+    Parameters:
+
+    path — The path to the virtual environment to manage.  This can be an existing
+    environment or not.
+
     """
-    requires_wipe = False
-    if refresh:
-        click.echo("Force package cache refresh.")
-        requires_wipe = True
 
-    manifest_file = os.path.join(package_root, "lektor-packages.manifest")
-    local_packages = list_local_packages(local_package_path)
+    def __init__(self, path: StrPath):
+        self.path = Path(path)
+
+    def create(self, with_pip: bool = True, upgrade_deps: bool = True) -> None:
+        """(Re-)Create a new virtual environment.
+
+        This will remove any existing virtual environment and create a new one.
+
+        The parameters ``with_pip`` and ``upgrade_deps`` should probably be left at
+        their default values in normal usage.  They are provided here mostly for use in
+        tests. They work as described for ``venv.EnvBuilder`` from the standard library.
+        (Though ``upgrade_deps`` is only supported by EnvBuilder`` in py39+, here we
+        emulate it's behavior if running under older pythons.)
+
+        """
+        # Right now, by default, we always install and upgrade pip to
+        # the latest available version.
+        #
+        # We could optimize by not installing (and not upgrading) pip if
+        # the system pip is sufficient to our needs.
+        #
+        # Note that, e.g., pip>=21.3 is required to support PEP660 editable
+        # installs.
+        options: Dict[str, Any] = {"clear": True, "with_pip": with_pip}
+        if sys.version_info >= (3, 9):
+            EnvBuilder(upgrade_deps=upgrade_deps, **options).create(self.path)
+        else:
+            EnvBuilder(**options).create(self.path)
+            if upgrade_deps:
+                self.run_pip_install("--upgrade", "pip", "setuptools")
+
+    def addsitedir(self, sitedir: str) -> None:
+        """Add an additional sitedir to sys.path for virtual environment.
+
+        Packages installed in ``sitedir`` will be made available to any invocations
+        of python running within the virtual environment.
+        """
+        with Path(self.site_packages, "_lektor.pth").open("a", encoding="utf-8") as fp:
+            fp.write(f"import site; site.addsitedir({sitedir!r})\n")
+
+    def run_pip_install(self, *args: str) -> None:
+        """Run `pip install` in the virtual environment.
+
+        ``Args`` are appended to the command line (following ``pip install``). They
+        should specify how and which packages to install.
+
+        """
+        try:
+            subprocess.run((self.executable, "-m", "pip", "install", *args), check=True)
+        except subprocess.CalledProcessError as exc:
+            raise RuntimeError("Failed to install dependency package.") from exc
 
-    old_manifest = load_manifest(manifest_file)
-    to_install = []
+    @property
+    def site_packages(self) -> str:
+        """The path to the virtual environments ``site-packages`` directory."""
+        return self._get_path("purelib")
 
-    all_packages = dict(remote_packages)
-    all_packages.update((x, None) for x in local_packages)
+    @property
+    def executable(self) -> str:
+        """The path to the python interpreter for the virtual environment."""
+        script_path = Path(self._get_path("scripts"))
+        executable_name = Path(sys.executable).name
+        return os.fspath(script_path / executable_name)
 
-    # step 1: figure out which remote packages to install.
-    for package, version in remote_packages.items():
-        old_version = old_manifest.pop(package, None)
-        if old_version is None:
-            to_install.append((package, version))
-        elif old_version != version:
-            requires_wipe = True
-
-    # step 2: figure out which local packages to install
-    for package in local_packages:
-        if old_manifest.pop(package, False) is False:
-            to_install.append((package, None))
-
-    # Bad news, we need to wipe everything
-    if requires_wipe or old_manifest:
+    def _get_path(self, name: str) -> str:
+        vars = {"base": os.fspath(self.path)}
+        return sysconfig.get_path(name, vars=vars)
+
+
+class Requirements(Iterable[str], Sized):
+    """Manage package requirements."""
+
+    requirements: Set[str]
+
+    def __init__(self) -> None:
+        self.requirements = set()
+
+    def __len__(self) -> int:
+        return len(self.requirements)
+
+    def __iter__(self) -> Iterator[str]:
+        """The requirements.
+
+        These requirements are in the form of arguments that can be passed to ``pip
+        install``.
+        """
+        return iter(self.requirements)
+
+    def add_requirement(self, package: str, version: Optional[str] = None) -> None:
+        """Add a (remote) distribution to the requirements."""
+        self.requirements.add(f"{package}=={version}" if version else f"{package}")
+
+    def add_local_requirement(self, path: StrPath) -> None:
+        """Add a local distribution source directory to the requirements.
+
+        The distribution source at ``path`` (which should be a legacy `setup.py` or
+        modern PEP660-compatible project) will be installed in editable mode.
+
+        """
+        srcdir = os.fspath(Path(path).resolve())
+        self.requirements.add(f"--editable={srcdir}")
+
+    _DIST_FILES = ("setup.py", "pyproject.toml")
+
+    def add_local_requirements_from(self, packages_path: StrPath) -> None:
+        """Add sub-directories of path that look like local distribution sources.
+
+        Any direct sub-directories of ``packages_path`` which appear to be distribution
+        source code will be added to the requirements in local (editable) mode.
+
+        """
         try:
-            shutil.rmtree(package_root)
+            for path in Path(packages_path).iterdir():
+                if any(path.joinpath(fn).is_file() for fn in self._DIST_FILES):
+                    self.add_local_requirement(path)
         except OSError:
             pass
-        to_install = all_packages.items()
 
-    if to_install:
-        click.echo("Updating packages in %s for project" % package_root)
+    def hash(self) -> str:
+        """Compute a hash of the requirement set."""
+        hash = hashlib.sha1()
+        for requirement in sorted(self.requirements):
+            hash.update(requirement.encode("utf-8"))
+            hash.update(b"\0")
+        return hash.hexdigest()
+
+
+def update_cache(
+    venv_path: Path,
+    remote_packages: dict[str, str],
+    local_package_path: Path,
+) -> None:
+    """Ensure the package cache at venv_path is up-to-date.
+
+    ``Remote_packages`` is a dictionary (mapping package names to required versions)
+    that specifies remote packages (to be installed from PyPI).
+
+    ``Local_package_page`` is a path to a directory whose sub-directories may contain
+    local plugin source.  Any such source directories will be installed in "editable"
+    mode.
+
+    """
+    requirements = Requirements()
+    for package, version in remote_packages.items():
+        requirements.add_requirement(package, version)
+    requirements.add_local_requirements_from(local_package_path)
+
+    if len(requirements) == 0:
+        shutil.rmtree(venv_path, ignore_errors=True)
+    else:
+        hash_file = venv_path / "lektor-requirements-hash.txt"
         try:
-            os.makedirs(package_root)
-        except OSError:
-            pass
-        for package, version in to_install:
-            if package[:1] == "@":
-                install_local_package(
-                    package_root, os.path.join(local_package_path, package[1:])
-                )
-            else:
-                download_and_install_package(package_root, package, version)
-        write_manifest(manifest_file, all_packages)
-
-
-def load_packages(env, reinstall=False):
-    """This loads all the packages of a project.  What this does is updating
-    the current cache in ``root/package-cache`` and then add the Python
-    modules there to the load path as a site directory and register it
-    appropriately with pkg_resource's workingset.
-
-    Afterwards all entry points should function as expected and imports
-    should be possible.
+            is_stale = hash_file.read_text().strip() != requirements.hash()
+        except FileNotFoundError:
+            is_stale = True
+
+        if is_stale:
+            venv = VirtualEnv(venv_path)
+            venv.create()
+            # Add our site-packages to venv's sys.path
+            our_site_packages = sysconfig.get_path("purelib")
+            venv.addsitedir(our_site_packages)
+
+            venv.run_pip_install(*requirements)
+            hash_file.write_text(f"{requirements.hash()}\n", encoding="ascii")
+
+
+def load_packages(env: "Environment", reinstall: bool = False) -> None:
+    """Import all of our managed plugins into our ``sys.path``
+
+    This first ensures that our private package cache is up-to-date, then
+    adds it to ``sys.path``.
+
+    After ``load_packages`` is called, the entry points defined in by
+    plugins that we manage will be available.
     """
+    if reinstall:
+        click.echo("Force package cache refresh.")
+        wipe_package_cache(env)
+
     config = env.load_config()
-    package_root = env.project.get_package_cache_path()
-    update_cache(
-        package_root,
-        config["PACKAGES"],
-        os.path.join(env.root_path, "packages"),
-        refresh=reinstall,
-    )
-    site.addsitedir(package_root)
-
-
-def wipe_package_cache(env):
-    """Wipes the entire package cache."""
-    package_root = env.project.get_package_cache_path()
-    try:
-        shutil.rmtree(package_root)
-    except (OSError, IOError):
-        pass
+    venv_path = env.project.get_package_cache_path()
+    update_cache(venv_path, config["PACKAGES"], Path(env.root_path, "packages"))
+    site.addsitedir(VirtualEnv(venv_path).site_packages)
+
+
+def wipe_package_cache(env: "Environment") -> None:
+    """Remove the entire package cache."""
+    project = env.project
+    # Remove the legacy flat package cache, too
+    for cache_type in project.PackageCacheType:
+        shutil.rmtree(project.get_package_cache_path(cache_type), ignore_errors=True)
diff --git a/lektor/pluginsystem.py b/lektor/pluginsystem.py
index 22058ca..2b1d2c2 100644
--- a/lektor/pluginsystem.py
+++ b/lektor/pluginsystem.py
@@ -4,6 +4,7 @@ import inspect
 import os
 import sys
 import warnings
+from pathlib import Path
 from typing import Type
 from weakref import ref as weakref
 
@@ -57,12 +58,18 @@ class Plugin:
         return None
 
     @property
-    def path(self):
+    def path(self) -> str:
         mod = sys.modules[self.__class__.__module__.split(".", maxsplit=1)[0]]
-        path = os.path.abspath(os.path.dirname(mod.__file__))
-        if not path.startswith(self.env.project.get_package_cache_path()):
-            return path
-        return None
+        path = Path(mod.__file__).resolve().parent
+        package_cache = self.env.project.get_package_cache_path()
+        try:
+            # We could use Path.is_relative_to(), except that's py39+ only
+            path.relative_to(package_cache)
+            # We're only interested in local, editable packages. This is not one.
+            return None
+        except ValueError:
+            pass
+        return os.fspath(path)
 
     @property
     def import_name(self):
@@ -209,6 +216,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/project.py b/lektor/project.py
index 5459bce..e2108fc 100644
--- a/lektor/project.py
+++ b/lektor/project.py
@@ -1,6 +1,10 @@
+from __future__ import annotations
+
 import hashlib
 import os
 import sys
+from enum import Enum
+from pathlib import Path
 
 from inifile import IniFile
 from werkzeug.utils import cached_property
@@ -111,13 +115,25 @@ class Project:
 
         return os.path.join(get_cache_dir(), "builds", self.id)
 
-    def get_package_cache_path(self):
+    class PackageCacheType(Enum):
+        VENV = "venv"  # The new virtual environment-based package cache
+        FLAT = "flat"  # No longer used flat-directory package cache
+
+    def get_package_cache_path(
+        self, cache_type: PackageCacheType = PackageCacheType.VENV
+    ) -> Path:
         """The path where plugin packages are stored."""
+        if cache_type is self.PackageCacheType.FLAT:
+            cache_name = "packages"
+        else:
+            cache_name = "venvs"
+
         h = hashlib.md5()
         h.update(self.id.encode("utf-8"))
         h.update(sys.version.encode("utf-8"))
         h.update(sys.prefix.encode("utf-8"))
-        return os.path.join(get_cache_dir(), "packages", h.hexdigest())
+
+        return Path(get_cache_dir(), cache_name, h.hexdigest())
 
     def content_path_from_filename(self, filename):
         """Given a filename returns the content path or None if
@@ -166,6 +182,7 @@ class Project:
             "project_file": self.project_file,
             "project_path": self.project_path,
             "default_output_path": self.get_output_path(),
+            "package_cache_path": str(self.get_package_cache_path()),
             "id": self.id,
             "tree": self.tree,
         }
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/quickstart-templates/plugin/.gitignore.in b/lektor/quickstart-templates/plugin/.gitignore.in
index 463960b..7653c29 100644
--- a/lektor/quickstart-templates/plugin/.gitignore.in
+++ b/lektor/quickstart-templates/plugin/.gitignore.in
@@ -1,5 +1,4 @@
-dist
-build
-*.pyc
-*.pyo
+/dist
+/build
+__pycache__
 *.egg-info
diff --git a/lektor/quickstart-templates/plugin/@plugin_module@.py.in b/lektor/quickstart-templates/plugin/@plugin_module@.py.in
index 1da5337..f10d95e 100644
--- a/lektor/quickstart-templates/plugin/@plugin_module@.py.in
+++ b/lektor/quickstart-templates/plugin/@plugin_module@.py.in
@@ -1,12 +1,11 @@
-# -*- coding: utf-8 -*-
 from lektor.pluginsystem import Plugin
 
 
 class ${plugin_class}(Plugin):
     name = ${plugin_name|pprint}
-    description = u'Add your description here.'
+    description = "Add your description here."
 
     def on_process_template_context(self, context, **extra):
         def test_function():
-            return 'Value from plugin %s' % self.name
-        context['test_function'] = test_function
+            return f"Value from plugin {self.name}"
+        context["test_function"] = test_function
diff --git a/lektor/quickstart-templates/plugin/pyproject.toml.in b/lektor/quickstart-templates/plugin/pyproject.toml.in
new file mode 100644
index 0000000..81ab8a3
--- /dev/null
+++ b/lektor/quickstart-templates/plugin/pyproject.toml.in
@@ -0,0 +1,3 @@
+[build-system]
+requires = ["setuptools>=45"]
+build-backend = "setuptools.build_meta"
diff --git a/lektor/quickstart-templates/plugin/setup.cfg.in b/lektor/quickstart-templates/plugin/setup.cfg.in
index 3c6e79c..6f2c8b5 100644
--- a/lektor/quickstart-templates/plugin/setup.cfg.in
+++ b/lektor/quickstart-templates/plugin/setup.cfg.in
@@ -1,2 +1,23 @@
-[bdist_wheel]
-universal=1
+[metadata]
+name = lektor-${plugin_id}
+version = 0.1
+# description = [Add your description here]
+# url = [link to your repository]
+author = ${author_name}
+author_email = ${author_email}
+license = MIT
+keywords = Lektor plugin
+long_description = file: README.md
+long_description_content_type = text/markdown
+classifiers =
+    Framework :: Lektor
+    Environment :: Plugins
+    Environment :: Web Environment
+    License :: OSI Approved :: MIT License
+
+[options]
+py_modules = ${plugin_module}
+
+[options.entry_points]
+lektor.plugins =
+    ${plugin_id} = ${plugin_module}:${plugin_class}
diff --git a/lektor/quickstart-templates/plugin/setup.py.in b/lektor/quickstart-templates/plugin/setup.py.in
deleted file mode 100644
index d02244c..0000000
--- a/lektor/quickstart-templates/plugin/setup.py.in
+++ /dev/null
@@ -1,38 +0,0 @@
-import ast
-import io
-import re
-
-from setuptools import setup, find_packages
-
-with io.open('README.md', 'rt', encoding="utf8") as f:
-    readme = f.read()
-
-_description_re = re.compile(r'description\s+=\s+(?P<description>.*)')
-
-with open('${plugin_module}.py', 'rb') as f:
-    description = str(ast.literal_eval(_description_re.search(
-        f.read().decode('utf-8')).group(1)))
-
-setup(
-    author=${author_name|pprint},
-    author_email='${author_email}',
-    description=description,
-    keywords='Lektor plugin',
-    license='MIT',
-    long_description=readme,
-    long_description_content_type='text/markdown',
-    name='lektor-${plugin_id}',
-    packages=find_packages(),
-    py_modules=['${plugin_module}'],
-    # url='[link to your repository]',
-    version='0.1',
-    classifiers=[
-        'Framework :: Lektor',
-        'Environment :: Plugins',
-    ],
-    entry_points={
-        'lektor.plugins': [
-            '${plugin_id} = ${plugin_module}:${plugin_class}',
-        ]
-    }
-)
diff --git a/lektor/sourceobj.py b/lektor/sourceobj.py
index 2121a35..1650da3 100644
--- a/lektor/sourceobj.py
+++ b/lektor/sourceobj.py
@@ -27,7 +27,13 @@ class SourceObject:
 
     @property
     def source_filename(self):
-        """The primary source filename of this source object."""
+        """The primary source filename of this source object.
+
+        In general, subclasses should implement/override ``iter_source_filenames``
+        rather than this property.
+        """
+        source_filenames = self.iter_source_filenames()
+        return next(iter(source_filenames), None)
 
     is_hidden = False
     is_discoverable = True
@@ -43,13 +49,12 @@ class SourceObject:
         return not self.is_discoverable
 
     def iter_source_filenames(self):
-        fn = self.source_filename
-        if fn is not None:
-            yield self.source_filename
+        """An iterable of the source filenames for this source object.
 
-    def iter_virtual_sources(self):
+        The first returned filename should be the "primary" one.
+        """
         # pylint: disable=no-self-use
-        return []
+        return ()
 
     @property
     def url_path(self):
@@ -205,13 +210,43 @@ class SourceObject:
         return resolved.geturl()
 
 
-class VirtualSourceObject(SourceObject):
+class DBSourceObject(SourceObject):
+    """This is the base class for objects that live in the lektor db.
+
+    I.e. this is the type of object returned by pad.get().
+
+    """
+
+    @property
+    def path(self):
+        """Return the full database path to the source object.
+
+        All DBSourceObjects must have paths.
+        """
+        raise NotImplementedError()
+
+    # XXX: move SourceObject.url_to here?
+
+    def __eq__(self, other):
+        if other is self:
+            return True  # optimization
+        if other.__class__ is not self.__class__:
+            return False  # optimization
+        return (
+            other.alt == self.alt and other.path == self.path and other.pad == self.pad
+        )
+
+    def __hash__(self):
+        return hash((self.path, self.alt))
+
+
+class VirtualSourceObject(DBSourceObject):
     """Virtual source objects live below a parent record but do not
     originate from the source tree with a separate file.
     """
 
     def __init__(self, record):
-        SourceObject.__init__(self, record.pad)
+        super().__init__(record.pad)
         self.record = record
 
     @property
@@ -234,9 +269,9 @@ class VirtualSourceObject(SourceObject):
     def alt(self):
         return self.record.alt
 
-    @property
-    def source_filename(self):
-        return self.record.source_filename
-
-    def iter_virtual_sources(self):
-        yield self
+    def iter_source_filenames(self):
+        # This is a default.  However, if artifacts produced from a
+        # particular virtual source type do not explicitly vary with
+        # the parent record, it may make sense to override this to
+        # return an empty (or some other) list of file names.
+        return self.record.iter_source_filenames()
diff --git a/lektor/types/primitives.py b/lektor/types/primitives.py
index e27a2f8..2ac6942 100644
--- a/lektor/types/primitives.py
+++ b/lektor/types/primitives.py
@@ -1,9 +1,9 @@
+import re
 from datetime import date
 from datetime import datetime
 
 from babel.dates import get_timezone
 from markupsafe import Markup
-from pytz import FixedOffset
 
 from lektor.constants import PRIMARY_ALT
 from lektor.i18n import get_i18n_block
@@ -117,25 +117,61 @@ class DateTimeType(SingleInputType):
     def value_from_raw(self, raw):
         if raw.value is None:
             return raw.missing_value("Missing datetime")
+
+        # The previous version of this code allowed a timezone name, followed by a zone
+        # offset. In that case the zone name would be ignored (unless the combined zone
+        # name, including the offset, matched an IANA zone key). For the sake of
+        # backwards compatibility we do the same here.
+        m = re.match(
+            r"""
+            (?P<datetime>
+                \d{4} - \d\d? - \d\d?  # YY-MM-DD
+                \s+ \d\d? : \d\d (?P<seconds> :\d\d )? # HH:MM[:SS]
+            )
+            (?: \s+
+                (?P<timezone>
+                    # Long timezone keys, and — on Windows — names containing
+                    # certain characters (those that are not allowed in filenames)
+                    # give zoneinfo gas.
+                    # https://github.com/python/cpython/issues/96463
+                    [^<>:"|?*\x00-\x1f]{,100}?
+                    (?P<zoneoffset> [-+] \d\d (?: :? \d\d ){1,2} )?  # ±HHMM[SS]
+                )
+            )?
+            \Z""",
+            raw.value.strip(),
+            re.DOTALL | re.VERBOSE,
+        )
+        if m is None:
+            return raw.bad_value("Bad datetime format")
+        timezone, zoneoffset = m.group("timezone", "zoneoffset")
+        tz = None
+        if timezone is not None:
+            try:
+                tz = get_timezone(timezone)
+                zoneoffset = None
+            except LookupError:
+                if zoneoffset is None:
+                    return raw.bad_value(f"Unknown timezone {timezone!r}")
+
+        dt = m["datetime"]
+        fmt = "%Y-%m-%d %H:%M"
+        if m["seconds"] is not None:
+            fmt += ":%S"
+        if zoneoffset is not None:
+            dt += f" {zoneoffset}"
+            fmt += " %z"
         try:
-            chunks = raw.value.split(" ")
-            date_info = [int(bit) for bit in chunks[0].split("-")]
-            time_info = [int(bit) for bit in chunks[1].split(":")]
-            datetime_info = date_info + time_info
-            result = datetime(*datetime_info)
-
-            if len(chunks) > 2:
-                try:
-                    tz = get_timezone(chunks[-1])
-                except LookupError:
-                    if len(chunks[-1]) > 5:
-                        chunks[-1] = chunks[-1][-5:]
-                    delta = int(chunks[-1][1:3]) * 60 + int(chunks[-1][3:])
-                    if chunks[-1][0] == "-":
-                        delta *= -1
-                    tz = FixedOffset(delta)
-                return tz.localize(result)
+            result = datetime.strptime(dt, fmt)
+        except ValueError:
+            return raw.bad_value("Invalid datetime")
 
+        if tz is None:
             return result
-        except Exception:
-            return raw.bad_value("Bad date format")
+
+        # as of babel 2.12, get_timezone can return either a pytz timezone
+        # or a zoneinfo timezone
+        assert result.tzinfo is None
+        if hasattr(tz, "localize"):  # pytz
+            return tz.localize(result)
+        return result.replace(tzinfo=tz)
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..4d00ed7 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -41,6 +41,7 @@ paths = [
 exclude_lines = [
     "pragma: no cover",
     '^\s*\.\.\.\s*$',
+    '^\s*raise\s+NotImplementedError\(\)\s*$',
 ]
 
 ################################################################
@@ -82,7 +83,7 @@ disable = [
 ]
 
 [tool.mypy]
-files = "$MYPY_CONFIG_FILE_DIR/lektor"
+files = ["$MYPY_CONFIG_FILE_DIR/lektor"]
 
 plugins = [
     "marshmallow_dataclass.mypy",
@@ -106,10 +107,19 @@ ignore_errors = true
 # Packages and modules we want to check
 module = [
     "lektor.constants",
+    "lektor.packages",
     "lektor.admin.*",
+    "test_packages",
 ]
 ignore_errors = false
 
+[[tool.mypy.overrides]]
+module = [
+    "lektor.packages",
+]
+disallow_untyped_defs = false
+disallow_untyped_calls = false
+
 [[tool.mypy.overrides]]
 module = [
     "lektor.admin.*",
diff --git a/setup.cfg b/setup.cfg
index 04aee9e..d250ad4 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
@@ -49,9 +48,10 @@ install_requires =
     marshmallow_dataclass>=8.5.9
     mistune>=0.7.0,<3
     pip
+    pytz; python_version<"3.9"  # favor zoneinfo for python>=3.9
     python-slugify
-    pytz
     requests
+    tzdata; python_version>="3.9" and sys_platform == 'win32'
     watchdog
     Werkzeug>=2.1.0,<3
 
diff --git a/tox.ini b/tox.ini
index 081a2a8..bd78be7 100644
--- a/tox.ini
+++ b/tox.ini
@@ -2,7 +2,10 @@
 minversion = 4.1
 envlist =
     lint
-    {py37,py38,py39,py310,py311}{,-mistune0},py311-noutils
+    {py37,py38,py39,py310,py311}{,-mistune0}
+    py311-noutils
+    py311-pytz
+    py311-tzdata
     cover-{clean,report}
 isolated_build = true
 
@@ -23,7 +26,11 @@ deps =
     pytest-click
     pytest-mock
     coverage[toml]
+    importlib_metadata; python_version<"3.10"
+    iniconfig
     mistune0: mistune<2
+    pytz: pytz
+    tzdata: tzdata
 depends =
     py{37,38,39,310,311}{,-mistune0,-noutils}: cover-clean
     cover-report: py{37,38,39,310,311}{,-mistune0,-noutils}
@@ -59,6 +66,9 @@ use_develop = true
 deps =
     pylint==2.15.8
     pytest>=6
+    pytest-mock
+    importlib_metadata; python_version<"3.10"
+    iniconfig
 commands =
     pylint {posargs:lektor tests}
 


Test patch:
diff --git a/tests/pep660-dummy-plugin/lektor_dummy_plugin.py b/tests/pep660-dummy-plugin/lektor_dummy_plugin.py
new file mode 100644
index 0000000..aa92196
--- /dev/null
+++ b/tests/pep660-dummy-plugin/lektor_dummy_plugin.py
@@ -0,0 +1,9 @@
+"""Dummy test plugin"""
+from lektor.pluginsystem import Plugin
+
+
+class DummyPlugin(Plugin):
+    """Dummy test plugin."""
+
+    # pylint: disable=too-few-public-methods
+    name = "dummy"
diff --git a/tests/pep660-dummy-plugin/pyproject.toml b/tests/pep660-dummy-plugin/pyproject.toml
new file mode 100644
index 0000000..9713f68
--- /dev/null
+++ b/tests/pep660-dummy-plugin/pyproject.toml
@@ -0,0 +1,14 @@
+[build-system]
+requires = ["setuptools>=61"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "lektor-dummy-plugin"
+description = "PEP660 test plugin"
+version = "0.1a42"
+
+[project.entry-points."lektor.plugins"]
+dummy = "lektor_dummy_plugin:DummyPlugin"
+
+[tool.setuptools]
+py-modules = ["lektor_dummy_plugin"]
diff --git a/tests/test_assets.py b/tests/test_assets.py
index 750c246..0dc760e 100644
--- a/tests/test_assets.py
+++ b/tests/test_assets.py
@@ -77,7 +77,7 @@ def test_asset_children(asset, child_names):
 
 @pytest.mark.parametrize("asset_path", ["/static"])
 def test_asset_children_no_children_if_dir_unreadable(asset):
-    asset.source_filename += "-missing"
+    asset._source_filename += "-missing"
     assert len(set(asset.children)) == 0
 
 
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_cli.py b/tests/test_cli.py
index 8e734c8..cc107cd 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -1,3 +1,4 @@
+import json
 import os
 import re
 
@@ -34,9 +35,9 @@ def test_alias(project_cli_runner):
 
 
 def test_dev_cmd_alias(isolated_cli_runner):
-    result = isolated_cli_runner.invoke(cli, ["dev", "p"])  # short for 'publish-plugin'
+    result = isolated_cli_runner.invoke(cli, ["dev", "s"])  # short for 'shell'
     assert result.exit_code == 2
-    assert "Error: This command must be run in a Lektor plugin folder" in result.output
+    assert "Error: Could not automatically discover project" in result.output
 
 
 def test_alias_multiple_matches(project_cli_runner):
@@ -94,21 +95,44 @@ def test_deploy_extra_flag(project_cli_runner, mocker):
     assert mock_publish.call_args[1]["extra_flags"] == ("draft",)
 
 
+@pytest.fixture
+def project_info_data(project_cli_runner):
+    tree_dir = os.getcwd()
+    project = Project.from_path(tree_dir)
+    return {
+        "name": "Demo Project",
+        "project_file": os.path.join(tree_dir, "Website.lektorproject"),
+        "tree": tree_dir,
+        # punt on computing these independently
+        "output_path": project.get_output_path(),
+        "package_cache": str(project.get_package_cache_path()),
+    }
+
+
+def test_project_info(project_cli_runner, project_info_data):
+    result = project_cli_runner.invoke(cli, ["project-info"])
+    for heading, key in [
+        ("Name", "name"),
+        ("File", "project_file"),
+        ("Tree", "tree"),
+        ("Output", "output_path"),
+        ("Package Cache", "package_cache"),
+    ]:
+        assert f"{heading}: {project_info_data[key]}\n" in result.stdout
+
+
 @pytest.mark.parametrize(
-    "flag, expect",
-    [
-        ("--name", "Demo Project"),
-        ("--project-file", "{tree_dir}{os.sep}Website.lektorproject"),
-        ("--tree", "{tree_dir}"),
-        ("--output-path", "{output_path}"),
-    ],
+    "flag",
+    ["--name", "--project-file", "--tree", "--output-path", "--package-cache"],
 )
-def test_project_info_path_flags(project_cli_runner, flag, expect):
-    tree_dir = os.getcwd()
+def test_project_info_path_flags(project_cli_runner, flag, project_info_data):
+    info_key = flag.lstrip("-").replace("-", "_")
     result = project_cli_runner.invoke(cli, ["project-info", flag])
     assert result.exit_code == 0
-    assert result.stdout.rstrip() == expect.format(
-        tree_dir=tree_dir,
-        output_path=Project.from_path(tree_dir).get_output_path(),
-        os=os,
-    )
+    assert result.stdout.rstrip() == project_info_data[info_key]
+
+
+def test_project_info_json(project_cli_runner):
+    project = Project.from_path(os.getcwd())
+    result = project_cli_runner.invoke(cli, ["project-info", "--json"])
+    assert json.loads(result.stdout) == project.to_json()
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_devcli.py b/tests/test_devcli.py
index 498e3f7..a478ab9 100644
--- a/tests/test_devcli.py
+++ b/tests/test_devcli.py
@@ -2,6 +2,7 @@ import os
 import textwrap
 
 import pytest
+from iniconfig import IniConfig
 from inifile import IniFile
 
 import lektor.quickstart
@@ -53,16 +54,21 @@ def test_new_plugin(project_cli_runner):
     assert result.exit_code == 0
     path = os.path.join("packages", "plugin-name")
     assert set(os.listdir(path)) == set(
-        ["lektor_plugin_name.py", "setup.cfg", "setup.py", ".gitignore", "README.md"]
+        [
+            "lektor_plugin_name.py",
+            "pyproject.toml",
+            "setup.cfg",
+            ".gitignore",
+            "README.md",
+        ]
     )
 
     # gitignore
     gitignore_expected = textwrap.dedent(
         """
-        dist
-        build
-        *.pyc
-        *.pyo
+        /dist
+        /build
+        __pycache__
         *.egg-info
     """
     ).strip()
@@ -83,69 +89,30 @@ def test_new_plugin(project_cli_runner):
         readme_contents = f.read().strip()
     assert readme_contents == readme_expected
 
-    # setup.py
-    setup_expected = textwrap.dedent(
-        """
-        import ast
-        import io
-        import re
-
-        from setuptools import setup, find_packages
-
-        with io.open('README.md', 'rt', encoding="utf8") as f:
-            readme = f.read()
-
-        _description_re = re.compile(r'description\\s+=\\s+(?P<description>.*)')
-
-        with open('lektor_plugin_name.py', 'rb') as f:
-            description = str(ast.literal_eval(_description_re.search(
-                f.read().decode('utf-8')).group(1)))
-
-        setup(
-            author='Author Name',
-            author_email='author@email.com',
-            description=description,
-            keywords='Lektor plugin',
-            license='MIT',
-            long_description=readme,
-            long_description_content_type='text/markdown',
-            name='lektor-plugin-name',
-            packages=find_packages(),
-            py_modules=['lektor_plugin_name'],
-            # url='[link to your repository]',
-            version='0.1',
-            classifiers=[
-                'Framework :: Lektor',
-                'Environment :: Plugins',
-            ],
-            entry_points={
-                'lektor.plugins': [
-                    'plugin-name = lektor_plugin_name:PluginNamePlugin',
-                ]
-            }
-        )
-    """
-    ).strip()
-
-    with open(os.path.join(path, "setup.py"), encoding="utf-8") as f:
-        setup_contents = f.read().strip()
-    assert setup_contents == setup_expected
+    setup = IniConfig(os.path.join(path, "setup.cfg"))
+    assert setup.get("metadata", "name") == "lektor-plugin-name"
+    assert setup.get("metadata", "author") == "Author Name"
+    assert setup.get("metadata", "author_email") == "author@email.com"
+    assert setup.get("options", "py_modules") == "lektor_plugin_name"
+    assert (
+        setup.get("options.entry_points", "lektor.plugins")
+        == "plugin-name = lektor_plugin_name:PluginNamePlugin"
+    )
 
     # plugin.py
     plugin_expected = textwrap.dedent(
         """
-        # -*- coding: utf-8 -*-
         from lektor.pluginsystem import Plugin
 
 
         class PluginNamePlugin(Plugin):
             name = 'Plugin Name'
-            description = u'Add your description here.'
+            description = "Add your description here."
 
             def on_process_template_context(self, context, **extra):
                 def test_function():
-                    return 'Value from plugin %s' % self.name
-                context['test_function'] = test_function
+                    return f"Value from plugin {self.name}"
+                context["test_function"] = test_function
     """
     ).strip()
     with open(os.path.join(path, "lektor_plugin_name.py"), encoding="utf-8") as f:
@@ -184,52 +151,9 @@ def test_new_plugin_name_only(project_cli_runner, default_author, default_author
     path = "packages"
     assert os.listdir(path) == ["plugin-name"]
 
-    # setup.py
-    setup_expected = textwrap.dedent(
-        f"""
-        import ast
-        import io
-        import re
-
-        from setuptools import setup, find_packages
-
-        with io.open('README.md', 'rt', encoding="utf8") as f:
-            readme = f.read()
-
-        _description_re = re.compile(r'description\\s+=\\s+(?P<description>.*)')
-
-        with open('lektor_plugin_name.py', 'rb') as f:
-            description = str(ast.literal_eval(_description_re.search(
-                f.read().decode('utf-8')).group(1)))
-
-        setup(
-            author='{default_author}',
-            author_email='{default_author_email}',
-            description=description,
-            keywords='Lektor plugin',
-            license='MIT',
-            long_description=readme,
-            long_description_content_type='text/markdown',
-            name='lektor-plugin-name',
-            packages=find_packages(),
-            py_modules=['lektor_plugin_name'],
-            # url='[link to your repository]',
-            version='0.1',
-            classifiers=[
-                'Framework :: Lektor',
-                'Environment :: Plugins',
-            ],
-            entry_points={{
-                'lektor.plugins': [
-                    'plugin-name = lektor_plugin_name:PluginNamePlugin',
-                ]
-            }}
-        )
-    """
-    ).strip()
-    with open(os.path.join(path, "plugin-name", "setup.py"), encoding="utf-8") as f:
-        setup_contents = f.read().strip()
-    assert setup_contents == setup_expected
+    setup = IniConfig(os.path.join(path, "plugin-name", "setup.cfg"))
+    assert setup.get("metadata", "author") == default_author
+    assert setup.get("metadata", "author_email") == default_author_email
 
 
 def test_new_plugin_name_param(project_cli_runner):
@@ -254,7 +178,13 @@ def test_new_plugin_path(project_cli_runner):
     assert result.exit_code == 0
     path = "path"
     assert set(os.listdir(path)) == set(
-        ["lektor_plugin_name.py", "setup.cfg", "setup.py", ".gitignore", "README.md"]
+        [
+            "lektor_plugin_name.py",
+            "pyproject.toml",
+            "setup.cfg",
+            ".gitignore",
+            "README.md",
+        ]
     )
 
 
@@ -268,7 +198,13 @@ def test_new_plugin_path_param(project_cli_runner):
     assert result.exit_code == 0
     path = "path"
     assert set(os.listdir(path)) == set(
-        ["lektor_plugin_name.py", "setup.cfg", "setup.py", ".gitignore", "README.md"]
+        [
+            "lektor_plugin_name.py",
+            "pyproject.toml",
+            "setup.cfg",
+            ".gitignore",
+            "README.md",
+        ]
     )
 
 
@@ -282,7 +218,13 @@ def test_new_plugin_path_and_name_params(project_cli_runner):
     assert result.exit_code == 0
     path = "path"
     assert set(os.listdir(path)) == set(
-        ["lektor_plugin_name.py", "setup.cfg", "setup.py", ".gitignore", "README.md"]
+        [
+            "lektor_plugin_name.py",
+            "pyproject.toml",
+            "setup.cfg",
+            ".gitignore",
+            "README.md",
+        ]
     )
 
 
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_packages.py b/tests/test_packages.py
new file mode 100644
index 0000000..966863a
--- /dev/null
+++ b/tests/test_packages.py
@@ -0,0 +1,201 @@
+import inspect
+import os
+import re
+import sys
+import sysconfig
+from pathlib import Path
+from subprocess import PIPE
+from subprocess import run
+
+import pytest
+from pytest_mock import MockerFixture
+
+from lektor.environment import Environment
+from lektor.packages import load_packages
+from lektor.packages import Requirements
+from lektor.packages import update_cache
+from lektor.packages import VirtualEnv
+from lektor.project import Project
+
+
+@pytest.fixture(scope="module")
+def nested_venv(tmp_path_factory: pytest.TempPathFactory) -> VirtualEnv:
+    """Create a lighweight nested virtual environment.
+
+    The created venv does not have anything installed in it — not even pip. It does,
+    however, have our ``site-packages`` directory added to its ``sys.path``, so it
+    should have access to a working ``pip`` that way.
+
+    This lightweight venv is relative quick to create.  Creating a full independent
+    venv involves running ``python -m ensurepip`` and potential network requests
+    to PyPI.
+    """
+    tmp_path = tmp_path_factory.mktemp("nested_venv")
+    venv = VirtualEnv(tmp_path)
+    # venv creation is very quick without installing/upgrading pip
+    venv.create(with_pip=False, upgrade_deps=False)
+
+    # add our site-packages to the venv's sys.path
+    venv.addsitedir(sysconfig.get_path("purelib"))
+    return venv
+
+
+def test_VirtualEnv_creates_site_packages(tmp_path: Path) -> None:
+    venv = VirtualEnv(tmp_path)
+    # venv creation is very quick without installing/upgrading pip
+    venv.create(with_pip=False, upgrade_deps=False)
+    assert Path(venv.site_packages).is_dir()
+
+
+def test_VirtualEnv_addsitedir(nested_venv: VirtualEnv) -> None:
+    # check that we can run pytest (presumably from our site-packages)
+    proc = run((nested_venv.executable, "-m", "pytest", "--version"), check=False)
+    assert proc.returncode == 0
+
+
+@pytest.mark.requiresinternet
+def test_VirtualEnv_run_pip_install(tmp_path: Path) -> None:
+    # XXX: slow test
+    venv = VirtualEnv(tmp_path)
+    venv.create()
+
+    # install a dummy plugin
+    plugin_path = Path(__file__).parent / "pep660-dummy-plugin"
+    dummy_plugin_path = os.fspath(plugin_path.resolve())
+    venv.run_pip_install(f"--editable={dummy_plugin_path}")
+
+    # Make our lektor available to the installed plugin
+    venv.addsitedir(sysconfig.get_path("purelib"))
+
+    # Check that we can load the plugin entry point
+    prog = inspect.cleandoc(
+        """
+        import sys
+        if sys.version_info < (3, 10):
+            # need "selectable" entry_points
+            import importlib_metadata as metadata
+        else:
+            from importlib import metadata
+
+        for ep in metadata.entry_points(group="lektor.plugins", name="dummy"):
+            print(ep.load().__name__)
+    """
+    )
+    proc = run((venv.executable, "-c", prog), stdout=PIPE, text=True, check=True)
+    assert proc.stdout.strip() == "DummyPlugin"
+
+
+def test_VirtualEnv_run_pip_install_raises_runtime_error(
+    nested_venv: VirtualEnv, capfd: pytest.CaptureFixture[str]
+) -> None:
+    with pytest.raises(RuntimeError) as excinfo:
+        nested_venv.run_pip_install("--unknown-option")
+    assert excinfo.match("Failed to install")
+    assert "no such option" in capfd.readouterr().err
+
+
+def test_VirtualEnv_site_packages(tmp_path: Path) -> None:
+    site_packages = VirtualEnv(tmp_path).site_packages
+    relpath = os.fspath(Path(site_packages).relative_to(tmp_path))
+    assert re.match(r"(?i)lib(?=[/\\]).*[/\\]site-packages\Z", relpath)
+
+
+def test_VirtualEnv_executable(tmp_path: Path) -> None:
+    executable = VirtualEnv(tmp_path).executable
+    relpath = os.fspath(Path(executable).relative_to(tmp_path))
+    assert re.match(r"(?i)(?:bin|Scripts)[/\\]python(?:\.\w*)?\Z", relpath)
+
+
+def test_Requirements_add_requirement() -> None:
+    requirements = Requirements()
+    requirements.add_requirement("foo", "1.2")
+    requirements.add_requirement("bar")
+    assert len(requirements) == 2
+    assert set(requirements) == {"foo==1.2", "bar"}
+
+
+def test_Requirements_add_local_requirement() -> None:
+    requirements = Requirements()
+    plugin_path = Path(__file__).parent / "pep660-dummy-plugin"
+    requirements.add_local_requirement(plugin_path)
+    assert set(requirements) == {f"--editable={os.fspath(plugin_path.resolve())}"}
+
+
+def test_Requirements_add_local_requirements_from(tmp_path: Path) -> None:
+    for fn in ["plugin1/pyproject.toml", "plugin2/setup.py", "notaplugin/README.md"]:
+        path = tmp_path / fn
+        path.parent.mkdir(parents=True, exist_ok=True)
+        path.touch()
+    requirements = Requirements()
+    requirements.add_local_requirements_from(tmp_path)
+    assert len(requirements) == 2
+    assert {req.rpartition(os.sep)[2] for req in requirements} == {"plugin1", "plugin2"}
+
+
+def test_Requirements_add_local_requirements_from_missing_dir(tmp_path: Path) -> None:
+    requirements = Requirements()
+    requirements.add_local_requirements_from(tmp_path / "missing")
+    assert len(requirements) == 0
+    assert not requirements
+
+
+def test_Requirements_hash() -> None:
+    requirements = Requirements()
+    assert requirements.hash() == "da39a3ee5e6b4b0d3255bfef95601890afd80709"
+    requirements.add_requirement("foo", "42")
+    assert requirements.hash() == "a44f078eab8bc1aa1ddfd111d63e24ff65131b4b"
+
+
+def test_update_cache_installs_requirements(
+    tmp_path: Path, mocker: MockerFixture
+) -> None:
+    venv_path = tmp_path / "cache"
+    venv_path.mkdir()
+    VirtualEnv = mocker.patch("lektor.packages.VirtualEnv")
+    update_cache(venv_path, {"foo": "42"}, tmp_path / "packages")
+    assert mocker.call().run_pip_install("foo==42") in VirtualEnv.mock_calls
+    hash_file = venv_path / "lektor-requirements-hash.txt"
+    assert hash_file.read_text().strip() == "a44f078eab8bc1aa1ddfd111d63e24ff65131b4b"
+
+
+def test_update_cache_skips_install_if_up_to_date(
+    tmp_path: Path, mocker: MockerFixture
+) -> None:
+    venv_path = tmp_path / "cache"
+    venv_path.mkdir()
+    venv_path.joinpath("lektor-requirements-hash.txt").write_text(
+        "a44f078eab8bc1aa1ddfd111d63e24ff65131b4b\n"
+    )
+    VirtualEnv = mocker.patch("lektor.packages.VirtualEnv")
+    update_cache(venv_path, {"foo": "42"}, tmp_path / "packages")
+    assert VirtualEnv.mock_calls == []
+
+
+def test_update_cache_removes_package_cache_if_no_requirements(tmp_path: Path) -> None:
+    venv_path = tmp_path / "cache"
+    venv_path.mkdir()
+
+    update_cache(venv_path, {}, tmp_path / "missing")
+    assert not venv_path.exists()
+
+
+def test_load_packages_add_package_cache_to_sys_path(env: Environment) -> None:
+    load_packages(env)
+    venv_path = env.project.get_package_cache_path()
+    site_packages = VirtualEnv(venv_path).site_packages
+    assert site_packages in sys.path
+
+
+PackageCacheType = Project.PackageCacheType
+
+
+@pytest.mark.parametrize("cache_type", PackageCacheType)
+def test_load_packages_reinstall_wipes_cache(
+    env: Environment, cache_type: PackageCacheType
+) -> None:
+    project = env.project
+    cache_path = project.get_package_cache_path(cache_type)
+    cache_path.mkdir(parents=True, exist_ok=False)
+
+    load_packages(env, reinstall=True)
+    assert not cache_path.exists()
diff --git a/tests/test_sourceobj.py b/tests/test_sourceobj.py
new file mode 100644
index 0000000..52502ef
--- /dev/null
+++ b/tests/test_sourceobj.py
@@ -0,0 +1,38 @@
+import pytest
+
+
+@pytest.mark.parametrize(
+    "key1, key2, is_eq",
+    [
+        ({"path": "/"}, {"path": "/"}, True),
+        ({"path": "/"}, {"path": "/", "alt": "en"}, True),
+        ({"path": "/"}, {"path": "/blog"}, False),
+        ({"path": "/blog/post1"}, {"path": "/blog/post1@siblings"}, False),
+        ({"path": "/"}, {"path": "/", "page_num": 1}, False),
+        ({"path": "/"}, {"path": "/", "alt": "de"}, False),
+        ({"path": "/", "alt": "en"}, {"path": "/", "alt": "de"}, False),
+    ],
+)
+def test_records_eq(pad, key1, key2, is_eq):
+    r1 = pad.get(**key1)
+    pad.cache.flush()
+    r2 = pad.get(**key2)
+    if is_eq:
+        assert r1 == r2
+        assert hash(r1) == hash(r2)
+    else:
+        assert r1 != r2
+
+
+def test_records_from_different_pads_ne(env):
+    pad1 = env.new_pad()
+    pad2 = env.new_pad()
+    assert pad1.get("/") == pad1.get("/")
+    assert pad1.get("/") != pad2.get("/")
+
+
+def test_asset_ne_record(pad):
+    record = pad.get("/")
+    asset = pad.get_asset("/")
+    assert record != asset
+    assert asset != record
diff --git a/tests/test_types.py b/tests/test_types.py
index 8650a80..5e22ab6 100644
--- a/tests/test_types.py
+++ b/tests/test_types.py
@@ -3,7 +3,6 @@ import re
 import warnings
 
 import pytest
-from babel.dates import get_timezone
 from markupsafe import escape
 from markupsafe import Markup
 
@@ -217,161 +216,89 @@ def test_boolean(env, pad):
             assert rv is False
 
 
-def test_datetime(env, pad):
-    field = make_field(env, "datetime")
+dt = datetime.datetime
+
 
+@pytest.mark.parametrize(
+    "value, expected",
+    [
+        ("2016-04-30 01:02:03", dt(2016, 4, 30, 1, 2, 3)),
+        ("1970-1-1 12:34", dt(1970, 1, 1, 12, 34)),
+        ("1970-01-02 12:34", dt(1970, 1, 2, 12, 34)),
+        ("2020-02-03 01:02:03", dt(2020, 2, 3, 1, 2, 3)),
+    ],
+)
+def test_datetime_no_timezone(env, pad, value, expected):
+    field = make_field(env, "datetime")
     with Context(pad=pad):
-        # default
-        rv = field.deserialize_value("2016-04-30 01:02:03", pad=pad)
-        assert isinstance(rv, datetime.datetime)
-        assert rv.year == 2016
-        assert rv.month == 4
-        assert rv.day == 30
-        assert rv.hour == 1
-        assert rv.minute == 2
-        assert rv.second == 3
-        assert rv.tzinfo is None
-
-        # It is not datetime, it is None
-        rv = field.deserialize_value(None, pad=pad)
-        assert isinstance(rv, Undefined)
+        rv = field.deserialize_value(value, pad=pad)
 
-        # It is not datetime, it is empty string
-        rv = field.deserialize_value("", pad=pad)
-        assert isinstance(rv, BadValue)
+    assert rv.replace(tzinfo=None) == expected
+    assert rv.tzinfo is None
 
-        # It is not datetime, it is date
-        rv = field.deserialize_value("2016-04-30", pad=pad)
-        assert isinstance(rv, BadValue)
 
+def utc(*args):
+    return datetime.datetime(*args, tzinfo=datetime.timezone.utc)
 
-def test_datetime_timezone_utc(env, pad):
-    field = make_field(env, "datetime")
-    with Context(pad=pad):
+
+@pytest.mark.parametrize(
+    "value, expected",
+    [
         # Known timezone name, UTC
-        rv = field.deserialize_value("2016-04-30 01:02:03 UTC", pad=pad)
-        assert isinstance(rv, datetime.datetime)
-        assert rv.year == 2016
-        assert rv.month == 4
-        assert rv.day == 30
-        assert rv.hour == 1
-        assert rv.minute == 2
-        assert rv.second == 3
-        assert rv.tzinfo is get_timezone("UTC")
-
-
-def test_datetime_timezone_est(env, pad):
-    field = make_field(env, "datetime")
-    with Context(pad=pad):
+        ("2016-04-30 01:02:03 UTC", utc(2016, 4, 30, 1, 2, 3)),
         # Known timezone name, EST
-        rv = field.deserialize_value("2016-04-30 01:02:03 EST", pad=pad)
-        assert isinstance(rv, datetime.datetime)
-        assert rv.year == 2016
-        assert rv.month == 4
-        assert rv.day == 30
-        assert rv.hour == 1
-        assert rv.minute == 2
-        assert rv.second == 3
-        assert rv.tzinfo is get_timezone("EST")
-
-
-def test_datetime_timezone_location(env, pad):
-    field = make_field(env, "datetime")
-    with Context(pad=pad):
+        ("2016-04-30 01:02:03 EST", utc(2016, 4, 30, 6, 2, 3)),
         # Known location name, Asia/Seoul
-        rv = field.deserialize_value("2016-04-30 01:02:03 Asia/Seoul", pad=pad)
-        assert isinstance(rv, datetime.datetime)
-        assert rv.year == 2016
-        assert rv.month == 4
-        assert rv.day == 30
-        assert rv.hour == 1
-        assert rv.minute == 2
-        assert rv.second == 3
-        tzinfos = get_timezone("Asia/Seoul")._tzinfos  # pylint: disable=no-member
-        assert rv.tzinfo in tzinfos.values()
-
-
-def test_datetime_timezone_kst(env, pad):
-    field = make_field(env, "datetime")
-    with Context(pad=pad):
+        ("2016-04-30 01:02:03 Asia/Seoul", utc(2016, 4, 29, 16, 2, 3)),
         # KST - http://www.timeanddate.com/time/zones/kst
-        rv = field.deserialize_value("2016-04-30 01:02:03 +0900", pad=pad)
-        assert isinstance(rv, datetime.datetime)
-        assert rv.year == 2016
-        assert rv.month == 4
-        assert rv.day == 30
-        assert rv.hour == 1
-        assert rv.minute == 2
-        assert rv.second == 3
-        assert rv.tzinfo._offset == datetime.timedelta(0, 9 * 60 * 60)
-
-
-def test_datetime_timezone_acst(env, pad):
-    field = make_field(env, "datetime")
-    with Context(pad=pad):
+        ("2016-04-30 01:02:03 +0900", utc(2016, 4, 29, 16, 2, 3)),
         # ACST - http://www.timeanddate.com/time/zones/acst
-        rv = field.deserialize_value("2016-04-30 01:02:03 +0930", pad=pad)
-        assert isinstance(rv, datetime.datetime)
-        assert rv.year == 2016
-        assert rv.month == 4
-        assert rv.day == 30
-        assert rv.hour == 1
-        assert rv.minute == 2
-        assert rv.second == 3
-        assert rv.tzinfo._offset == datetime.timedelta(0, (9 * 60 + 30) * 60)
-
-
-def test_datetime_timezone_mst(env, pad):
+        ("2016-04-30 01:02:03 +0930", utc(2016, 4, 29, 15, 32, 3)),
+        # MST - http://www.timeanddate.com/time/zones/mst
+        ("2016-04-30 01:02:03 -0700", utc(2016, 4, 30, 8, 2, 3)),
+        # MART - http://www.timeanddate.com/time/zones/mart
+        ("2016-04-30 01:02:03 -0930", utc(2016, 4, 30, 10, 32, 3)),
+        # with (ignored) timezone name (case 1)
+        ("2016-04-30 01:02:03 KST +0900", utc(2016, 4, 29, 16, 2, 3)),
+        # with (ignored) timezone name (case 2)
+        ("2016-04-30 01:02:03 KST+0900", utc(2016, 4, 29, 16, 2, 3)),
+    ],
+)
+def test_datetime_timezone(env, pad, value, expected):
     field = make_field(env, "datetime")
     with Context(pad=pad):
-        # MST - http://www.timeanddate.com/time/zones/mst
-        rv = field.deserialize_value("2016-04-30 01:02:03 -0700", pad=pad)
-        assert isinstance(rv, datetime.datetime)
-        assert rv.year == 2016
-        assert rv.month == 4
-        assert rv.day == 30
-        assert rv.hour == 1
-        assert rv.minute == 2
-        assert rv.second == 3
-        assert rv.tzinfo._offset == datetime.timedelta(0, -7 * 60 * 60)
-
-
-def test_datetime_timezone_mart(env, pad):
+        rv = field.deserialize_value(value, pad=pad)
+    assert rv.astimezone(expected.tzinfo) == expected
+
+
+@pytest.mark.parametrize(
+    "value",
+    [
+        "",
+        "197",
+        "1970",
+        "1970-01",
+        "1970-01-01",
+        "1970-01-01 12",
+        "1970-01-01 12.23",
+        "1970-01 01:02:03",
+        "1970-01-01 12:34 *0800",
+        "1970-01-01 12:34 -081",
+        "1970-01-01 12:34 a\\b",
+        "1970-01-01 12:34 very/unknown/timezone",
+        "1970-01-01 12:34 very/long/timezone" + "e" * 1024,
+    ],
+)
+def test_datetime_invalid(env, pad, value):
     field = make_field(env, "datetime")
     with Context(pad=pad):
-        # MART - http://www.timeanddate.com/time/zones/mart
-        rv = field.deserialize_value("2016-04-30 01:02:03 -0930", pad=pad)
-        assert isinstance(rv, datetime.datetime)
-        assert rv.year == 2016
-        assert rv.month == 4
-        assert rv.day == 30
-        assert rv.hour == 1
-        assert rv.minute == 2
-        assert rv.second == 3
-        assert rv.tzinfo._offset == datetime.timedelta(0, -(9 * 60 + 30) * 60)
-
-
-def test_datetime_timezone_name(env, pad):
+        rv = field.deserialize_value(value, pad=pad)
+    assert isinstance(rv, BadValue)
+
+
+def test_datetime_missing(env, pad):
     field = make_field(env, "datetime")
     with Context(pad=pad):
-        # with timezone name (case 1)
-        rv = field.deserialize_value("2016-04-30 01:02:03 KST +0900", pad=pad)
-        assert isinstance(rv, datetime.datetime)
-        assert rv.year == 2016
-        assert rv.month == 4
-        assert rv.day == 30
-        assert rv.hour == 1
-        assert rv.minute == 2
-        assert rv.second == 3
-        assert rv.tzinfo._offset == datetime.timedelta(0, 9 * 60 * 60)
-
-        # with timezone name (case 2)
-        rv = field.deserialize_value("2016-04-30 01:02:03 KST+0900", pad=pad)
-        assert isinstance(rv, datetime.datetime)
-        assert rv.year == 2016
-        assert rv.month == 4
-        assert rv.day == 30
-        assert rv.hour == 1
-        assert rv.minute == 2
-        assert rv.second == 3
-        assert rv.tzinfo._offset == datetime.timedelta(0, 9 * 60 * 60)
+        rv = field.deserialize_value(None, pad=pad)
+    assert isinstance(rv, Undefined)
+    assert "Missing value" in rv._undefined_hint
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)