Repository: lektor/lektor
Pull request #1074 (2022-09-21 01:32:13)

Enable a default set of mistune2 plugins for feature parity with mistune0
The features provided by the [mistune2 plugins] `url`, `strikethrough`, `footnotes`, and `table` provide features which were supported by mistune 0.8.4 out of the box.

For the sake of backwards-compatibility, since these features were all supported by older versions of Lektor (due to use of mistune 0.x) we should enable these plugins by default when running with mistune2. 

This PR does that.

[mistune2 plugins]: https://mistune.readthedocs.io/en/latest/plugins.html

<!---
Remember to please check that your code passes tests locally. Test with the Makefile
commands `make test`, or if you only need to test just Python / JS `make test-python`
or `make test-js`.
--->

### Issue(s) Resolved

<!--- List the issue(s) below, in the form "Fixes #1234"; one per line --->

When using mistune >= 2, markdown features which were previously supported under mistune 0.x are disabled by default.

### Related Issues / Links

This builds upon the work to support mistune 2 in PR #992.

### Description of Changes

- [x] Wrote at least one-line docstrings (for any new functions)
- [x] Added unit test(s) covering the changes (if testable)




Patch:
diff --git a/CHANGES.md b/CHANGES.md
index d622556..2fe7a2e 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -23,7 +23,7 @@ These are all the changes in Lektor since the first public release.
 
 #### Bit-Rot
 
-- Disuse `pkg_resources` in favor of `importlib.metadata`. ([#1061][])
+- Disuse `pkg_resources` in favor of `importlib.metadata`. ([#1061][], [#1073][])
 - Update frontend dependencies, including upgrade to React version 18. ([#1063][])
 
 #### Packaging
@@ -36,6 +36,7 @@ These are all the changes in Lektor since the first public release.
 [#1063]: https://github.com/lektor/lektor/pull/1063
 [#1064]: https://github.com/lektor/lektor/pull/1064
 [#1066]: https://github.com/lektor/lektor/pull/1066
+[#1073]: https://github.com/lektor/lektor/pull/1073
 
 ## 3.4.0b2 (2022-08-31)
 
diff --git a/lektor/compat.py b/lektor/compat.py
index 95c61fe..b73e3c2 100644
--- a/lektor/compat.py
+++ b/lektor/compat.py
@@ -51,11 +51,7 @@ class FixedTemporaryDirectory(tempfile.TemporaryDirectory):
 
 if sys.version_info >= (3, 8):
     TemporaryDirectory = tempfile.TemporaryDirectory
-else:
-    TemporaryDirectory = FixedTemporaryDirectory
-
-if sys.version_info >= (3, 10):
     from importlib import metadata as importlib_metadata
 else:
-    # we use importlib.metadata.packages_distributions() which is new in python 3.10
+    TemporaryDirectory = FixedTemporaryDirectory
     import importlib_metadata
diff --git a/lektor/markdown/controller.py b/lektor/markdown/controller.py
index 2fbbebd..aa5d96a 100644
--- a/lektor/markdown/controller.py
+++ b/lektor/markdown/controller.py
@@ -127,6 +127,10 @@ class RendererHelper:
         )
 
 
+class UnknownPluginError(LookupError):
+    """Exception raised when a mistune2 plugin name can not be resolved."""
+
+
 class RenderResult(NamedTuple):
     html: str
     meta: Meta
diff --git a/lektor/markdown/mistune2.py b/lektor/markdown/mistune2.py
index e86ad2a..15e8dd8 100644
--- a/lektor/markdown/mistune2.py
+++ b/lektor/markdown/mistune2.py
@@ -1,5 +1,8 @@
 """MarkdownController implementation for mistune 2.x"""
+from __future__ import annotations
+
 import sys
+from importlib import import_module
 from typing import Any
 from typing import Callable
 from typing import ClassVar
@@ -12,6 +15,8 @@ import mistune  # type: ignore[import]
 
 from lektor.markdown.controller import MarkdownController
 from lektor.markdown.controller import RendererHelper
+from lektor.markdown.controller import UnknownPluginError
+from lektor.utils import unique_everseen
 
 
 class ImprovedRenderer(mistune.HTMLRenderer):  # type: ignore[misc]
@@ -40,7 +45,14 @@ else:
         plugins: Sequence[Callable[[mistune.Markdown], None]]
 
 
+MistunePlugin = Callable[[mistune.Markdown], None]
+
+
 class MarkdownConfig:
+    # Enabling these plugins give us feature parity with mistune 0.8.4.
+    # We enable them by default for the sake of backwards-compatibility.
+    DEFAULT_PLUGINS = ("url", "strikethrough", "footnotes", "table")
+
     def __init__(self) -> None:
         self.renderer_options = {
             "escape": False,
@@ -48,7 +60,9 @@ class MarkdownConfig:
         }
         self.renderer_base = ImprovedRenderer
         self.renderer_mixins: List[type] = []
-        self.parser_options: ParserConfigDict = {}
+        self.parser_options: ParserConfigDict = {
+            "plugins": list(self.DEFAULT_PLUGINS),
+        }
 
     def make_renderer(self) -> ImprovedRenderer:
         bases = tuple(self.renderer_mixins) + (self.renderer_base,)
@@ -66,4 +80,32 @@ class MarkdownController2(MarkdownController):
         env.plugin_controller.emit(
             "markdown-lexer-config", config=cfg, renderer=renderer
         )
+        parser_options = cfg.parser_options
+        plugins = parser_options.get("plugins")
+        if plugins:
+            # Resolve and filter out duplicates
+            parser_options["plugins"] = tuple(
+                unique_everseen(map(self.resolve_plugin, plugins))
+            )
         return mistune.Markdown(renderer, **cfg.parser_options)
+
+    PLUGINS = {**mistune.PLUGINS}
+
+    def resolve_plugin(self, plugin: str | MistunePlugin) -> MistunePlugin:
+        if callable(plugin):
+            return plugin
+        if not isinstance(plugin, str):
+            raise TypeError(
+                f"Plugins should be specified as strings or callables, not {plugin!r}"
+            )
+
+        module_name, sep, attr = plugin.partition(":")
+        if sep:
+            try:
+                return getattr(import_module(module_name.strip()), attr.strip())
+            except (ImportError, AttributeError) as exc:
+                raise UnknownPluginError(f"Can not load plugin {plugin!r}") from exc
+        try:
+            return self.PLUGINS[plugin]
+        except KeyError as exc:
+            raise UnknownPluginError(f"Unknown plugin {plugin!r}") from exc
diff --git a/lektor/pluginsystem.py b/lektor/pluginsystem.py
index 090af97..4258870 100644
--- a/lektor/pluginsystem.py
+++ b/lektor/pluginsystem.py
@@ -1,6 +1,9 @@
+from __future__ import annotations
+
 import os
 import sys
 import warnings
+from typing import Type
 from weakref import ref as weakref
 
 from inifile import IniFile
@@ -33,6 +36,8 @@ class Plugin:
     name = "Your Plugin Name"
     description = "Description goes here"
 
+    __dist: metadata.Distribution = None
+
     def __init__(self, env, id):
         self._env = weakref(env)
         self.id = id
@@ -46,10 +51,9 @@ class Plugin:
 
     @property
     def version(self):
-        try:
-            return metadata.version(_dist_name_for_module(self.__module__))
-        except LookupError:
-            return None
+        if self.__dist is not None:
+            return self.__dist.version
+        return None
 
     @property
     def path(self):
@@ -109,32 +113,39 @@ class Plugin:
         }
 
 
-def load_plugins():
-    """Loads all available plugins and returns them."""
-    rv = {}
-    for ep in metadata.entry_points(  # pylint: disable=unexpected-keyword-arg
-        group="lektor.plugins"
-    ):
-        # XXX: do we really need to be so strict about distribution names?
-        match_name = "lektor-" + ep.name.lower()
-        try:
-            dist_name = _dist_name_for_module(ep.module)
-        except LookupError:
-            dist_name = ""
-        if match_name != dist_name.lower():
-            raise RuntimeError(
-                "Disallowed distribution name: distribution name for "
-                f"plugin {ep.name!r} must be {match_name!r} (not {dist_name!r})."
-            )
-        rv[ep.name] = ep.load()
-    return rv
+def _find_plugins():
+    """Find all available plugins.
+
+    Returns an interator of (distribution, entry_point) pairs.
+    """
+    for dist in metadata.distributions():
+        for ep in dist.entry_points:
+            if ep.group == "lektor.plugins":
+                _check_dist_name(dist.metadata["Name"], ep.name)
+                yield dist, ep
+
+
+def _check_dist_name(dist_name, plugin_id):
+    """Check that plugin comes from a validly named distribution.
+
+    Raises RuntimeError if distribution name is not of the form
+    ``lektor-``*<plugin_id>*.
+    """
+    # XXX: do we really need to be so strict about distribution names?
+    match_name = "lektor-" + plugin_id.lower()
+    if match_name != dist_name.lower():
+        raise RuntimeError(
+            "Disallowed distribution name: distribution name for "
+            f"plugin {plugin_id!r} must be {match_name!r} (not {dist_name!r})."
+        )
 
 
 def initialize_plugins(env):
     """Initializes the plugins for the environment."""
-    plugins = load_plugins()
-    for plugin_id, plugin_cls in plugins.items():
-        env.plugin_controller.instanciate_plugin(plugin_id, plugin_cls)
+    for dist, ep in _find_plugins():
+        plugin_id = ep.name
+        plugin_cls = ep.load()
+        env.plugin_controller.instanciate_plugin(plugin_id, plugin_cls, dist)
     env.plugin_controller.emit("setup-env")
 
 
@@ -154,11 +165,22 @@ class PluginController:
             raise RuntimeError("Environment went away")
         return rv
 
-    def instanciate_plugin(self, plugin_id, plugin_cls):
+    def instanciate_plugin(
+        self,
+        plugin_id: str,
+        plugin_cls: Type[Plugin],
+        dist: metadata.Distribution | None = None,
+    ) -> None:
         env = self.env
         if plugin_id in env.plugins:
             raise RuntimeError('Plugin "%s" is already registered' % plugin_id)
-        env.plugins[plugin_id] = plugin_cls(env, plugin_id)
+        plugin = plugin_cls(env, plugin_id)
+        # Plugin.version needs the source distribution to be able to cleanly determine
+        # the plugin version.  For reasons of backward compatibility, we don't want to
+        # change the signature of the constructor, so we stick it in a private attribute
+        # here.
+        plugin._Plugin__dist = dist
+        env.plugins[plugin_id] = plugin
         env.plugin_ids_by_class[plugin_cls] = plugin_id
 
     def iter_plugins(self):
@@ -187,12 +209,3 @@ class PluginController:
                         DeprecationWarning,
                     )
         return rv
-
-
-def _dist_name_for_module(module: str) -> "str | None":
-    """Return the name of the distribution which provides the named module."""
-    top_level = module.partition(".")[0]
-    distributions = metadata.packages_distributions().get(top_level, [])
-    if len(distributions) != 1:
-        raise LookupError(f"Can not find distribution for {module}")
-    return distributions[0]
diff --git a/lektor/utils.py b/lektor/utils.py
index a989409..a47bdd8 100644
--- a/lektor/utils.py
+++ b/lektor/utils.py
@@ -13,6 +13,9 @@ from contextlib import contextmanager
 from datetime import datetime
 from functools import lru_cache
 from pathlib import PurePosixPath
+from typing import Hashable
+from typing import Iterable
+from typing import TypeVar
 from urllib.parse import urlsplit
 
 from jinja2 import is_undefined
@@ -695,3 +698,17 @@ def process_extra_flags(flags):
         else:
             rv[flag] = flag
     return rv
+
+
+_H = TypeVar("_H", bound=Hashable)
+
+
+def unique_everseen(seq: Iterable[_H]) -> Iterable[_H]:
+    """Filter out duplicates from iterable."""
+    # This is a less general version of more_itertools.unique_everseen.
+    # Should we need more general functionality, consider using that instead.
+    seen = set()
+    for val in seq:
+        if val not in seen:
+            seen.add(val)
+            yield val
diff --git a/setup.cfg b/setup.cfg
index 88c74dd..baae718 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -40,7 +40,7 @@ install_requires =
     EXIFRead
     filetype>=1.0.7
     Flask
-    importlib_metadata; python_version<"3.10"
+    importlib_metadata; python_version<"3.8"
     inifile>=0.4.1
     Jinja2>=3.0
     MarkupSafe


Test patch:
diff --git a/tests/test_markdown.py b/tests/test_markdown.py
index 3b37ceb..7332d17 100644
--- a/tests/test_markdown.py
+++ b/tests/test_markdown.py
@@ -16,6 +16,7 @@ from lektor.markdown.controller import get_renderer_context
 from lektor.markdown.controller import RendererContext
 from lektor.markdown.controller import RendererHelper
 from lektor.markdown.controller import require_ctx
+from lektor.markdown.controller import UnknownPluginError
 from lektor.pluginsystem import Plugin
 
 
@@ -160,6 +161,42 @@ def test_controller_cache(env):
     assert get_controller(env) is controller
 
 
+if MISTUNE_VERSION.startswith("0."):
+    plugin_url = ...
+else:
+    from mistune.plugins import plugin_url
+
+
+def plugin_null(md):
+    return md
+
+
+@pytest.mark.skipif(MISTUNE_VERSION.startswith("0."), reason="mistune0")
+@pytest.mark.parametrize(
+    "plugin, resolved",
+    [
+        (plugin_null, plugin_null),  # callable is already resolved
+        ("url", plugin_url),  # resolve through mistune.PLUGINS
+        ("mistune.plugins:plugin_url", plugin_url),  # resolve via import
+    ],
+)
+def test_markdown_controller_resolve_plugin(plugin, resolved, markdown_controller):
+    assert markdown_controller.resolve_plugin(plugin) is resolved
+
+
+@pytest.mark.skipif(MISTUNE_VERSION.startswith("0."), reason="mistune0")
+@pytest.mark.parametrize("plugin", ["badplugin", "unknown_module:badplugin"])
+def test_markdown_controller_resolve_plugin_unknown(plugin, markdown_controller):
+    with pytest.raises(UnknownPluginError):
+        markdown_controller.resolve_plugin(plugin)
+
+
+@pytest.mark.skipif(MISTUNE_VERSION.startswith("0."), reason="mistune0")
+def test_markdown_controller_resolve_plugin_raises_typeerror(markdown_controller):
+    with pytest.raises(TypeError):
+        markdown_controller.resolve_plugin(None)
+
+
 @pytest.fixture
 def improved_renderer():
     # pylint: disable=import-outside-toplevel
@@ -170,22 +207,19 @@ def improved_renderer():
     return ImprovedRenderer()
 
 
-if MISTUNE_VERSION.startswith("0."):
+@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:
+        assert improved_renderer.record is record
+    assert re.search(r"Use .*Renderer\.lektor.record instead", str(warnings[0].message))
 
-    @pytest.mark.usefixtures("renderer_context")
-    def test_ImprovedRenderer_record(record, improved_renderer):
-        with pytest.deprecated_call() as warnings:
-            assert improved_renderer.record is record
-        assert re.search(
-            r"Use .*Renderer\.lektor.record instead", str(warnings[0].message)
-        )
 
-    def test_ImprovedRenderer_meta(renderer_context, improved_renderer):
-        with pytest.deprecated_call() as warnings:
-            assert improved_renderer.meta is renderer_context.meta
-        assert re.search(
-            r"Use .*Renderer\.lektor.meta instead", str(warnings[0].message)
-        )
+@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:
+        assert improved_renderer.meta is renderer_context.meta
+    assert re.search(r"Use .*Renderer\.lektor.meta instead", str(warnings[0].message))
 
 
 @pytest.mark.parametrize(
@@ -249,6 +283,22 @@ def test_markdown_to_html(record, field_options):
     assert result.html.rstrip() == "<p>goober</p>"
 
 
+def plugin_linkcount(md):
+    """A test mistune2 plugin.
+
+    This replaces ``[link-count]`` in text with the current link count
+    wrapped in a <code> element.
+    """
+
+    def parse_linkcount(inline, _m, _state):
+        nlinks = inline.renderer.lektor.meta["nlinks"]
+        return "codespan", str(nlinks)
+
+    md.inline.register_rule("link_count", r"\[link-count\]", parse_linkcount)
+    index = md.inline.rules.index("ref_link2")
+    md.inline.rules.insert(index, "link_count")
+
+
 class LinkCounterPlugin(Plugin):
     """A test plugin to test the renderer meta system."""
 
@@ -263,6 +313,13 @@ class LinkCounterPlugin(Plugin):
     def on_markdown_config(self, config, **kwargs):
         config.renderer_mixins.append(self.RendererMixin)
 
+    @staticmethod
+    def on_markdown_lexer_config(config, renderer, **kwargs):
+        if not hasattr(config, "parser_options"):
+            return  # mistune 0
+        # Configure a plugin just to make sure we can
+        config.parser_options.setdefault("plugins", []).append(plugin_linkcount)
+
     def on_markdown_meta_init(self, meta, **kwargs):
         # pylint: disable=no-self-use
         meta["nlinks"] = 0
@@ -326,6 +383,13 @@ class TestMarkdown:
     def test_getitem(self, markdown):
         assert markdown["nlinks"] == 0
 
+    @pytest.mark.parametrize("source", ["[x](/y) [link-count]"])
+    @pytest.mark.usefixtures("context", "link_counter_plugin")
+    @pytest.mark.skipif(MISTUNE_VERSION.startswith("0."), reason="mistune0")
+    def test_linkcount_plugin(self, markdown):
+        assert markdown["nlinks"] == 1
+        assert re.search(r"<code>1</code>", markdown.html)
+
     @pytest.mark.usefixtures("context")
     def test_str(self, markdown):
         assert str(markdown).rstrip() == "<p>text</p>"
@@ -378,6 +442,18 @@ def _normalize_html(output: Union[str, Markup]) -> str:
         ("![](x&amp;y.gif)", r'.*<img\b[^>]* src="x&amp;y.gif"'),
         # FIXME: which of these is right?
         ("![](x<br>y.gif)", r'.*<img\b[^>]* src="x(&lt;br&gt;|%3Cbr%3E)y.gif"'),
+        # Autolink
+        ("autolink: <http://example.org>", r'.*<a href="http://example\.org"'),
+        #################
+        # Extra features enabled by default in mistune 0.8.4, but which require
+        # plugins to be enabled in mistune 2
+        ("strikethrough: ~~stricken~~ foo", r".*<del>stricken</del> foo"),
+        (
+            "footnotes[^note]\n\n[^note]: comment",
+            r'(?s).*<a href="#fn-([^"]+)">.*<li id="fn-\1">',
+        ),
+        ("| Table Header |\n| -------|\n| Cell |\n", r"(?s).*<tr>"),
+        ("raw URL - http://example.net/ foo", r'.*<a href="http://example\.net/"'),
     ],
 )
 @pytest.mark.usefixtures("context")
diff --git a/tests/test_pluginsystem.py b/tests/test_pluginsystem.py
index a061f98..c0b7f85 100644
--- a/tests/test_pluginsystem.py
+++ b/tests/test_pluginsystem.py
@@ -13,9 +13,9 @@ from lektor.cli import cli
 from lektor.compat import importlib_metadata as metadata
 from lektor.context import Context
 from lektor.packages import add_package_to_project
-from lektor.pluginsystem import _dist_name_for_module
+from lektor.pluginsystem import _check_dist_name
+from lektor.pluginsystem import _find_plugins
 from lektor.pluginsystem import get_plugin
-from lektor.pluginsystem import load_plugins
 from lektor.pluginsystem import Plugin
 from lektor.pluginsystem import PluginController
 
@@ -107,18 +107,11 @@ class DummyPluginFinder(metadata.DistributionFinder):
 
 
 @pytest.fixture
-def dummy_plugin_distribution_name():
-    return "lektor-dummy-plugin"
-
-
-@pytest.fixture
-def dummy_plugin_distribution(
-    dummy_plugin_distribution_name, save_sys_path, monkeypatch
-):
+def dummy_plugin_distribution(save_sys_path, monkeypatch):
     """Add a dummy plugin distribution to the current working_set."""
     dist = DummyDistribution(
         {
-            "Name": dummy_plugin_distribution_name,
+            "Name": "lektor-dummy-plugin",
             "Version": "1.23",
         }
     )
@@ -128,9 +121,11 @@ def dummy_plugin_distribution(
 
 
 @pytest.fixture
-def dummy_plugin(env):
+def dummy_plugin(env, dummy_plugin_distribution):
     """Instantiate and register a dummy plugin in env"""
-    env.plugin_controller.instanciate_plugin("dummy-plugin", DummyPlugin)
+    env.plugin_controller.instanciate_plugin(
+        "dummy-plugin", DummyPlugin, dummy_plugin_distribution
+    )
     return env.plugins["dummy-plugin"]
 
 
@@ -165,9 +160,11 @@ class TestPlugin:
         return scratch_project_data
 
     @pytest.fixture
-    def scratch_plugin(self, scratch_env):
+    def scratch_plugin(self, scratch_env, dummy_plugin_distribution):
         """Instantiate and register a dummy plugin with scratch_env"""
-        scratch_env.plugin_controller.instanciate_plugin("dummy-plugin", DummyPlugin)
+        scratch_env.plugin_controller.instanciate_plugin(
+            "dummy-plugin", DummyPlugin, dummy_plugin_distribution
+        )
         return scratch_env.plugins["dummy-plugin"]
 
     def test_env(self, dummy_plugin, env):
@@ -183,6 +180,12 @@ class TestPlugin:
     def test_version(self, dummy_plugin, dummy_plugin_distribution):
         assert dummy_plugin.version == dummy_plugin_distribution.version
 
+    def test_version_missing(self, env):
+        # Instantiate plugin without specifying distribution
+        env.plugin_controller.instanciate_plugin("dummy-plugin", DummyPlugin)
+        plugin = env.plugins["dummy-plugin"]
+        assert plugin.version is None
+
     def test_path(self, dummy_plugin):
         assert dummy_plugin.path == str(Path(__file__).parent)
 
@@ -252,14 +255,30 @@ class TestPlugin:
         }
 
 
-def test_load_plugins(dummy_plugin_distribution):
-    assert load_plugins() == {"dummy-plugin": DummyPlugin}
+def test_find_plugins(dummy_plugin_distribution):
+    (ep,) = dummy_plugin_distribution.entry_points
+    assert list(_find_plugins()) == [(dummy_plugin_distribution, ep)]
+
 
+@pytest.mark.parametrize(
+    "dist_name, plugin_id",
+    [
+        ("Lektor-FOO", "Foo"),
+    ],
+)
+def test_check_dist_name(dist_name, plugin_id):
+    _check_dist_name(dist_name, plugin_id)
 
-@pytest.mark.parametrize("dummy_plugin_distribution_name", ["evil-dist-name"])
-def test_load_plugins_bad_distname(dummy_plugin_distribution):
-    with pytest.raises(RuntimeError, match=r"Disallowed distribution name"):
-        load_plugins()
+
+@pytest.mark.parametrize(
+    "dist_name, plugin_id",
+    [
+        ("NotLektor-FOO", "Foo"),
+    ],
+)
+def test_check_dist_name_raises(dist_name, plugin_id):
+    with pytest.raises(RuntimeError):
+        _check_dist_name(dist_name, plugin_id)
 
 
 class TestPluginController:
@@ -328,12 +347,3 @@ def test_cli_integration(project, cli_runner, monkeypatch):
     )
     for call in DummyPlugin.calls:
         assert call["extra_flags"] == {"flag1": "flag1", "flag2": "value2"}
-
-
-def test_dist_name_form_module():
-    assert _dist_name_for_module("lektor.db") == "Lektor"
-
-
-def test_dist_name_form_module_raises_exception():
-    with pytest.raises(LookupError):
-        assert _dist_name_for_module("missing.module")
diff --git a/tests/test_utils.py b/tests/test_utils.py
index 4dee2d1..19d998a 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -8,6 +8,7 @@ from lektor.utils import magic_split_ext
 from lektor.utils import make_relative_url
 from lektor.utils import parse_path
 from lektor.utils import slugify
+from lektor.utils import unique_everseen
 
 
 def test_join_path():
@@ -127,3 +128,15 @@ def test_make_relative_url(source, target, expected):
 def test_make_relative_url_relative_source_absolute_target():
     with pytest.raises(ValueError):
         make_relative_url("rel/a/tive/", "/abs/o/lute")
+
+
+@pytest.mark.parametrize(
+    "seq, expected",
+    [
+        (iter(()), ()),
+        ((2, 1, 1, 2, 1), (2, 1)),
+        ((1, 2, 1, 2, 1), (1, 2)),
+    ],
+)
+def test_unique_everseen(seq, expected):
+    assert tuple(unique_everseen(seq)) == expected