Repository: brettcannon/mousebender

Issue:
Support PEP 629 and 700 for HTML responses
It technically doesn't change the outcome, but https://peps.python.org/pep-0700/#specification says HTML is supported, so proper detection of HTML versions is necessary (i.e. https://peps.python.org/pep-0629/ ).

Comments:

Patch:
diff --git a/docs/simple.rst b/docs/simple.rst
index 17ddd96..50eaa12 100644
--- a/docs/simple.rst
+++ b/docs/simple.rst
@@ -21,6 +21,15 @@
 
    .. versionadded:: 2022.1.0
 
+.. autoexception:: UnsupportedAPIVersion
+
+   .. versionadded:: 2023.0.0
+
+
+.. autoexception:: APIVersionWarning
+
+   .. versionadded:: 2023.0.0
+
 .. autoexception:: UnsupportedMIMEType
 
    .. versionadded:: 2022.1.0
diff --git a/mousebender/simple.py b/mousebender/simple.py
index 9659eee..7c2f1f4 100644
--- a/mousebender/simple.py
+++ b/mousebender/simple.py
@@ -5,9 +5,9 @@ This module helps with the JSON-based Simple repository API by providing
 responses, functions are provided to convert the HTML to the equivalent JSON
 response.
 
-This module implements :pep:`503`, :pep:`592`, :pep:`658`, and :pep:`691` of the
-:external:ref:`Simple repository API <simple-repository-api>` (it forgoes
-:pep:`629` as :pep:`691` makes it obsolete).
+This module implements :pep:`503`, :pep:`592`, :pep:`629`, :pep:`658`,
+:pep:`691`, and :pep:`700` of the
+:external:ref:`Simple repository API <simple-repository-api>`.
 
 """
 from __future__ import annotations
@@ -16,6 +16,7 @@ import html
 import html.parser
 import json
 import urllib.parse
+import warnings
 from typing import Any, Dict, List, Optional, Union
 
 import packaging.utils
@@ -47,6 +48,22 @@ ACCEPT_SUPPORTED = ", ".join(
 :func:`parse_project_details` support."""
 
 
+class UnsupportedAPIVersion(Exception):
+    """The major version of an API response is not supported."""
+
+    def __init__(self, version: str) -> None:
+        """Initialize the exception with a message based on the provided version."""
+        super().__init__(f"Unsupported API major version: {version!r}")
+
+
+class APIVersionWarning(Warning):
+    """The minor version of an API response is not supported."""
+
+    def __init__(self, version: str) -> None:
+        """Initialize the warning with a message based on the provided version."""
+        super().__init__(f"Unsupported API minor version: {version!r}")
+
+
 class UnsupportedMIMEType(Exception):
     """An unsupported MIME type was provided in a ``Content-Type`` header."""
 
@@ -139,6 +156,21 @@ class ProjectDetails_1_1(TypedDict):
 ProjectDetails: TypeAlias = Union[ProjectDetails_1_0, ProjectDetails_1_1]
 
 
+def _check_version(tag: str, attrs: Dict[str, Optional[str]]) -> None:
+    if (
+        tag == "meta"
+        and attrs.get("name") == "pypi:repository-version"
+        and "content" in attrs
+        and attrs["content"]
+    ):
+        version = attrs["content"]
+        major_version, minor_version = map(int, version.split("."))
+        if major_version != 1:
+            raise UnsupportedAPIVersion(version)
+        elif minor_version > 1:
+            warnings.warn(APIVersionWarning(version), stacklevel=7)
+
+
 class _SimpleIndexHTMLParser(html.parser.HTMLParser):
     # PEP 503:
     # Within a repository, the root URL (/) MUST be a valid HTML5 page with a
@@ -150,8 +182,9 @@ class _SimpleIndexHTMLParser(html.parser.HTMLParser):
         self.names: List[str] = []
 
     def handle_starttag(
-        self, tag: str, _attrs_list: list[tuple[str, Optional[str]]]
+        self, tag: str, attrs_list: list[tuple[str, Optional[str]]]
     ) -> None:
+        _check_version(tag, dict(attrs_list))
         if tag != "a":
             return
         self._parsing_anchor = True
@@ -186,6 +219,7 @@ class _ArchiveLinkHTMLParser(html.parser.HTMLParser):
         self, tag: str, attrs_list: list[tuple[str, Optional[str]]]
     ) -> None:
         attrs = dict(attrs_list)
+        _check_version(tag, attrs)
         if tag != "a":
             return
         # PEP 503:
diff --git a/noxfile.py b/noxfile.py
index 506227e..a1255a0 100644
--- a/noxfile.py
+++ b/noxfile.py
@@ -3,7 +3,7 @@
 All sessions prefixed with `check_` are non-destructive.
 
 """
-import nox
+import nox  # type: ignore[import]
 
 python_versions = ["3.7", "3.8", "3.9", "3.10", "3.11"]
 
diff --git a/pyproject.toml b/pyproject.toml
index 16dbff0..846c4eb 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -5,7 +5,7 @@ build-backend = "flit_core.buildapi"
 
 [project]
 name = "mousebender"
-version = "2022.1.0"
+version = "2023.0.0"
 authors = [
     { name = "Brett Cannon", email = "brett@python.org" },
     { name = "Derek Keeler", email = "derek@suchcool.ca" },
@@ -54,7 +54,7 @@ report.fail_under = 100
 profile = "black"
 
 [tool.ruff]
-select = ["E", "F", "W", "D", "C", "B", "A", "ANN", "RUF", "I"]
+select = ["E", "F", "W", "D", "B", "A", "ANN", "RUF", "I"]
 ignore = ["E501", "D203", "D213", "ANN101"]
 
 [tool.ruff.per-file-ignores]


Test patch:
diff --git a/tests/test_simple.py b/tests/test_simple.py
index d9cc92e..d44c500 100644
--- a/tests/test_simple.py
+++ b/tests/test_simple.py
@@ -1,5 +1,6 @@
 """Tests for mousebender.simple."""
 import json
+import warnings
 from typing import Dict, Union
 
 import importlib_resources
@@ -331,6 +332,55 @@ class TestProjectDetailsParsing:
         assert details["files"][0].get("yanked") == expected
 
 
+class TestPEP629Versioning:
+    @pytest.mark.parametrize(["version"], [("",), ("1.0",), ("1.1",)])
+    def test_supported_versions(self, version):
+        if not version:
+            meta_tag = ""
+        else:
+            meta_tag = f'<meta name="pypi:repository-version" content="{version}">'
+
+        index_html = (
+            f"<!DOCTYPE html><html><head>{meta_tag}</head>"
+            '<body><a href="/spamspamspam/">spamspamspam</a></body></html>'
+        )
+
+        assert simple.from_project_index_html(index_html)
+
+        details_html = (
+            f"<!DOCTYPE html><html><head>{meta_tag}</head>"
+            '<body><a href="mousebender-2022.1.0-py3-none-any.whl">'
+            "mousebender-2022.1.0-py3-none-any.whl/a></body></html>"
+        )
+
+        assert simple.from_project_details_html(details_html, "mousebender")
+
+    @pytest.mark.parametrize(["version"], [("0.1",), ("2.0",), ("2.1",), ("10.0",)])
+    def test_unsupported_major_versions(self, version):
+        meta_tag = f'<meta name="pypi:repository-version" content="{version}">'
+        index_html = (
+            f"<!DOCTYPE html><html><head>{meta_tag}</head>"
+            '<body><a href="/spamspamspam/">spamspamspam</a></body></html>'
+        )
+
+        with pytest.raises(simple.UnsupportedAPIVersion):
+            simple.from_project_index_html(index_html)
+
+    @pytest.mark.parametrize(["minor_version"], [("2",), ("10",)])
+    def test_unsupported_minor_version(self, minor_version):
+        meta_tag = f'<meta name="pypi:repository-version" content="1.{minor_version}">'
+        details_html = (
+            f"<!DOCTYPE html><html><head>{meta_tag}</head>"
+            '<body><a href="mousebender-2022.1.0-py3-none-any.whl">'
+            "mousebender-2022.1.0-py3-none-any.whl/a></body></html>"
+        )
+
+        with warnings.catch_warnings():
+            warnings.simplefilter("error")
+            with pytest.raises(simple.APIVersionWarning):
+                simple.from_project_details_html(details_html, "mousebender")
+
+
 class TestPEP658Metadata:
     def test_default(self):
         html = '<a href="spam-1.2.3-py3.none.any.whl">spam-1.2.3-py3.none.any.whl</a>'