Repository: brettcannon/mousebender

Issue:
Support PEP 714
https://peps.python.org/pep-0714/

Discussion:

Patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index f663945..dbfd0c5 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -35,7 +35,7 @@ jobs:
     runs-on: ubuntu-latest
     strategy:
       matrix:
-        python_version: ["3.7", "3.8", "3.9", "3.10", "3.11"]
+        python_version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
 
     steps:
       - uses: actions/checkout@v4
diff --git a/mousebender/simple.py b/mousebender/simple.py
index 8c79891..59f12da 100644
--- a/mousebender/simple.py
+++ b/mousebender/simple.py
@@ -6,7 +6,7 @@ responses, functions are provided to convert the HTML to the equivalent JSON
 response.
 
 This module implements :pep:`503`, :pep:`592`, :pep:`629`, :pep:`658`,
-:pep:`691`, and :pep:`700` of the
+:pep:`691`, :pep:`700`, and :pep:`714` of the
 :external:ref:`Simple repository API <simple-repository-api>`.
 
 """
@@ -17,12 +17,12 @@ import html.parser
 import json
 import urllib.parse
 import warnings
-from typing import Any, Dict, List, Optional, Union
+from typing import Any, Dict, List, Literal, Optional, TypedDict, Union
 
 import packaging.utils
 
-# Python 3.8+ only.
-from typing_extensions import Literal, TypeAlias, TypedDict
+# Python < 3.10
+from typing_extensions import TypeAlias
 
 ACCEPT_JSON_LATEST = "application/vnd.pypi.simple.latest+json"
 """The ``Accept`` header value for the latest version of the JSON API.
@@ -98,6 +98,8 @@ _OptionalProjectFileDetails_1_0 = TypedDict(
         "dist-info-metadata": Union[bool, _HashesDict],
         "gpg-sig": bool,
         "yanked": Union[bool, str],
+        # PEP 714
+        "core-metadata": Union[bool, _HashesDict],  # PEP 714
     },
     total=False,
 )
@@ -115,11 +117,13 @@ _OptionalProjectFileDetails_1_1 = TypedDict(
     "_OptionalProjectFileDetails_1_1",
     {
         "requires-python": str,
-        "dist-info-metadata": Union[bool, _HashesDict],
+        "dist-info-metadata": Union[bool, _HashesDict],  # Deprecated by PEP 714
         "gpg-sig": bool,
         "yanked": Union[bool, str],
         # PEP 700
         "upload-time": str,
+        # PEP 714
+        "core-metadata": Union[bool, _HashesDict],  # PEP 714
     },
     total=False,
 )
@@ -263,8 +267,14 @@ class _ArchiveLinkHTMLParser(html.parser.HTMLParser):
         # PEP 658:
         # ... each anchor tag pointing to a distribution MAY have a
         # data-dist-info-metadata attribute.
-        if "data-dist-info-metadata" in attrs:
-            found_metadata = attrs.get("data-dist-info-metadata")
+        # PEP 714:
+        # Clients consuming any of the HTML representations of the Simple API
+        # MUST read the PEP 658 metadata from the key data-core-metadata if it
+        # is present. They MAY optionally use the legacy data-dist-info-metadata
+        # if it is present but data-core-metadata is not.
+        metadata_fields = ["data-core-metadata", "data-dist-info-metadata"]
+        if any((metadata_field := field) in attrs for field in metadata_fields):
+            found_metadata = attrs.get(metadata_field)
             if found_metadata and found_metadata != "true":
                 # The repository SHOULD provide the hash of the Core Metadata
                 # file as the data-dist-info-metadata attribute's value using
@@ -316,12 +326,14 @@ def from_project_details_html(html: str, name: str) -> ProjectDetails_1_0:
         if "metadata" in archive_link:
             algorithm, value = archive_link["metadata"]
             if algorithm:
-                details["dist-info-metadata"] = {algorithm: value}
+                value = {algorithm: value}
             else:
-                details["dist-info-metadata"] = True
+                value = True
+            for key in ["core-metadata", "dist-info-metadata"]:
+                details[key] = value  # type: ignore[literal-required]
         for key in {"requires-python", "yanked", "gpg-sig"}:
             if key in archive_link:
-                details[key] = archive_link[key]  # type: ignore
+                details[key] = archive_link[key]  # type: ignore[literal-required]
         files.append(details)
     return {
         "meta": {"api-version": "1.0"},
diff --git a/noxfile.py b/noxfile.py
index a1255a0..14fc9db 100644
--- a/noxfile.py
+++ b/noxfile.py
@@ -5,7 +5,7 @@ All sessions prefixed with `check_` are non-destructive.
 """
 import nox  # type: ignore[import]
 
-python_versions = ["3.7", "3.8", "3.9", "3.10", "3.11"]
+python_versions = ["3.8", "3.9", "3.10", "3.11", "3.12"]
 
 
 @nox.session(python=python_versions)
diff --git a/pyproject.toml b/pyproject.toml
index 846c4eb..e38489e 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -11,7 +11,7 @@ authors = [
     { name = "Derek Keeler", email = "derek@suchcool.ca" },
 ]
 readme = "README.md"
-requires-python = ">=3.7"
+requires-python = ">=3.8"
 license = { file = "LICENSE" }
 keywords = ["packaging", "Simple Repository API", "PEP 503", "PEP 592", "PEP 658", "PEP 691"]
 classifiers = [


Test patch:
diff --git a/tests/test_simple.py b/tests/test_simple.py
index d44c500..b7a16f2 100644
--- a/tests/test_simple.py
+++ b/tests/test_simple.py
@@ -3,6 +3,7 @@ import json
 import warnings
 from typing import Dict, Union
 
+# Python < 3.9
 import importlib_resources
 import pytest
 
@@ -387,28 +388,25 @@ class TestPEP658Metadata:
         details = simple.from_project_details_html(html, "test_default")
         assert len(details["files"]) == 1
         # Need to make sure it isn't an empty dict.
+        assert "core-metadata" not in details["files"][0]
         assert "dist-info-metadata" not in details["files"][0]
 
-    @pytest.mark.parametrize(
-        "attribute", ["data-dist-info-metadata", "data-dist-info-metadata=true"]
-    )
-    def test_attribute_only(self, attribute: str):
-        html = f'<a href="spam-1.2.3-py3.none.any.whl" {attribute} >spam-1.2.3-py3.none.any.whl</a>'
+    @pytest.mark.parametrize("field", ["data-core-metadata", "data-dist-info-metadata"])
+    @pytest.mark.parametrize("value", ["", "=true"])
+    def test_attribute_only(self, field: str, value: str):
+        html = f'<a href="spam-1.2.3-py3.none.any.whl" {field}{value} >spam-1.2.3-py3.none.any.whl</a>'
         details = simple.from_project_details_html(html, "test_default")
         assert len(details["files"]) == 1
+        assert details["files"][0]["core-metadata"] is True
         assert details["files"][0]["dist-info-metadata"] is True
 
-    @pytest.mark.parametrize(
-        "attribute",
-        [
-            'data-dist-info-metadata="sha256=abcdef"',
-            'data-dist-info-metadata="SHA256=abcdef"',
-        ],
-    )
-    def test_hash(self, attribute: str):
-        html = f'<a href="spam-1.2.3-py3.none.any.whl" {attribute}>spam-1.2.3-py3.none.any.whl</a>'
+    @pytest.mark.parametrize("field", ["data-core-metadata", "data-dist-info-metadata"])
+    @pytest.mark.parametrize("value", ['"sha256=abcdef"', '"SHA256=abcdef"'])
+    def test_hash(self, field: str, value: str):
+        html = f'<a href="spam-1.2.3-py3.none.any.whl" {field}={value}>spam-1.2.3-py3.none.any.whl</a>'
         details = simple.from_project_details_html(html, "test_default")
         assert len(details["files"]) == 1
+        assert details["files"][0]["core-metadata"] == {"sha256": "abcdef"}
         assert details["files"][0]["dist-info-metadata"] == {"sha256": "abcdef"}