Repository: brettcannon/mousebender (language: python)

Issue:
Provide a function to read an HTTP response regardless of its `Content-Type`
The idea is to let someone use #75 to make an HTTP request to a URL and then blindly pass the text body and `Content-Type` line to get back the appropriate JSON. That way whether something returns HTML or JSON does not matter to the end user.


Discussion / hints:

Fix patch:
diff --git a/docs/conf.py b/docs/conf.py
index 42d49a4..69394db 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -18,7 +18,7 @@
 
 project = pyproject["project"]["name"]
 author = ", ".join(author["name"] for author in pyproject["project"]["authors"])
-copyright = f"2022, {author}"
+copyright = f"2022, {author}"  # noqa: A001
 version = release = pyproject["project"]["version"]
 
 # -- General configuration ---------------------------------------------------
diff --git a/docs/simple.rst b/docs/simple.rst
index 9b2d684..090f362 100644
--- a/docs/simple.rst
+++ b/docs/simple.rst
@@ -5,6 +5,26 @@
 
 .. automodule:: mousebender.simple
 
+.. autodata:: ACCEPT_JSON_LATEST
+
+   .. versionadded:: 2022.1.0
+
+.. autodata:: ACCEPT_JSON_V1
+
+   .. versionadded:: 2022.1.0
+
+.. autodata:: ACCEPT_HTML
+
+   .. versionadded:: 2022.1.0
+
+.. autodata:: ACCEPT_SUPPORTED
+
+   .. versionadded:: 2022.1.0
+
+.. autoexception:: UnsupportedMIMEType
+
+   .. versionadded:: 2022.1.0
+
 .. autodata:: ProjectIndex_1_0
    :no-value:
 
@@ -39,3 +59,11 @@
 .. autofunction:: from_project_details_html
 
    .. versionadded:: 2022.0.0
+
+.. autofunction:: parse_project_index
+
+   .. versionadded:: 2022.1.0
+
+.. autofunction:: parse_project_details
+
+   .. versionadded:: 2022.1.0
diff --git a/mousebender/simple.py b/mousebender/simple.py
index 50c0b0c..f67ec91 100644
--- a/mousebender/simple.py
+++ b/mousebender/simple.py
@@ -14,6 +14,7 @@
 
 import html
 import html.parser
+import json
 import urllib.parse
 from typing import Any, Dict, List, Optional, Union
 
@@ -23,6 +24,34 @@
 # Python 3.8+ only.
 from typing_extensions import Literal, TypeAlias, TypedDict
 
+ACCEPT_JSON_LATEST = "application/vnd.pypi.simple.latest+json"
+"""The ``Accept`` header value for the latest version of the JSON API.
+
+Use of this value is generally discouraged as major versions of the JSON API are
+not guaranteed to be backwards compatible, and thus may result in a response
+that code cannot handle.
+
+"""
+ACCEPT_JSON_V1 = "application/vnd.pypi.simple.v1+json"
+"""The ``Accept`` header value for version 1 of the JSON API."""
+_ACCEPT_HTML_VALUES = ["application/vnd.pypi.simple.v1+html", "text/html"]
+ACCEPT_HTML = f"{_ACCEPT_HTML_VALUES[0]}, {_ACCEPT_HTML_VALUES[1]};q=0.01"
+"""The ``Accept`` header value for the HTML API."""
+ACCEPT_SUPPORTED = ", ".join(
+    [
+        ACCEPT_JSON_V1,
+        f"{_ACCEPT_HTML_VALUES[0]};q=0.02",
+        f"{_ACCEPT_HTML_VALUES[1]};q=0.01",
+    ]
+)
+"""The ``Accept`` header for the MIME types that :func:`parse_project_index` and
+:func:`parse_project_details` support."""
+
+
+class UnsupportedMIMEType(Exception):
+    """An unsupported MIME type was provided in a ``Content-Type`` header."""
+
+
 _Meta_1_0 = TypedDict("_Meta_1_0", {"api-version": Literal["1.0"]})
 
 
@@ -230,3 +259,41 @@ def from_project_details_html(html: str, name: str) -> ProjectDetails_1_0:
         "name": packaging.utils.canonicalize_name(name),
         "files": files,
     }
+
+
+def parse_project_index(data: str, content_type: str) -> ProjectIndex:
+    """Parse an HTTP response for a project index.
+
+    The text of the body and ``Content-Type`` header are expected to be passed
+    in as *data* and *content_type* respectively. This allows for the user to
+    not have to concern themselves with what form the response came back in.
+
+    If the specified *content_type* is not supported,
+    :exc:`UnsupportedMIMEType` is raised.
+    """
+    if content_type == ACCEPT_JSON_V1:
+        return json.loads(data)
+    elif any(content_type.startswith(mime_type) for mime_type in _ACCEPT_HTML_VALUES):
+        return from_project_index_html(data)
+    else:
+        raise UnsupportedMIMEType(f"Unsupported MIME type: {content_type}")
+
+
+def parse_project_details(data: str, content_type: str, name: str) -> ProjectDetails:
+    """Parse an HTTP response for a project's details.
+
+    The text of the body and ``Content-Type`` header are expected to be passed
+    in as *data* and *content_type* respectively. This allows for the user to
+    not have to concern themselves with what form the response came back in.
+    The *name* parameter is for the name of the projet whose details have been
+    fetched.
+
+    If the specified *content_type* is not supported,
+    :exc:`UnsupportedMIMEType` is raised.
+    """
+    if content_type == ACCEPT_JSON_V1:
+        return json.loads(data)
+    elif any(content_type.startswith(mime_type) for mime_type in _ACCEPT_HTML_VALUES):
+        return from_project_details_html(data, name)
+    else:
+        raise UnsupportedMIMEType(f"Unsupported MIME type: {content_type}")
diff --git a/pyproject.toml b/pyproject.toml
index d43ef06..eeeb6d6 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -54,6 +54,10 @@ report.fail_under = 100
 profile = "black"
 
 [tool.ruff]
-select = ["E", "F", "W", "D", "C", "B", "A", "ANN", "RUF", "M", "I"]
+select = ["E", "F", "W", "D", "C", "B", "A", "ANN", "RUF", "I"]
 ignore = ["E501", "D203", "D213", "ANN101"]
-per-file-ignores = { "tests/*" = ["D", "ANN"], "noxfile.py" = ["ANN", "A001"] }
+
+[tool.ruff.per-file-ignores]
+"tests/*" = ["D", "ANN"]
+"noxfile.py" = ["ANN", "A001"]
+"docs/conf.py" = ["D100"]


Test patch:
diff --git a/tests/test_simple.py b/tests/test_simple.py
index 4422111..b5d3d1e 100644
--- a/tests/test_simple.py
+++ b/tests/test_simple.py
@@ -1,4 +1,6 @@
 """Tests for mousebender.simple."""
+import json
+
 import importlib_resources
 import pytest
 
@@ -6,6 +8,52 @@
 
 from .data import simple as simple_data
 
+INDEX_v1_EXAMPLE = """{
+  "meta": {
+    "api-version": "1.0"
+  },
+  "projects": [
+    {"name": "Frob"},
+    {"name": "spamspamspam"}
+  ]
+}"""
+
+INDEX_HTML_EXAMPLE = """<!DOCTYPE html>
+<html>
+  <body>
+    <a href="/frob/">Frob</a>
+    <a href="/spamspamspam/">spamspamspam</a>
+  </body>
+</html>"""
+
+DETAILS_V1_EXAMPLE = """{
+  "meta": {
+    "api-version": "1.0"
+  },
+  "name": "holygrail",
+  "files": [
+    {
+      "filename": "holygrail-1.0.tar.gz",
+      "url": "https://example.com/files/holygrail-1.0.tar.gz",
+      "hashes": {}
+    },
+    {
+      "filename": "holygrail-1.0-py3-none-any.whl",
+      "url": "https://example.com/files/holygrail-1.0-py3-none-any.whl",
+      "hashes": {}
+    }
+  ]
+}"""
+
+
+DETAILS_HTML_EXAMPLE = """<!DOCTYPE html>
+<html>
+  <body>
+    <a href="https://example.com/files/holygrail-1.0.tar.gz">holygrail-1.0.tar.gz</a>
+    <a href="https://example.com/files/holygrail-1.0-py3-none-any.whl">holygrail-1.0-py3-none-any.whl</a>
+  </body>
+</html>"""
+
 
 class TestProjectURLConstruction:
     @pytest.mark.parametrize("base_url", ["/simple/", "/simple"])
@@ -306,3 +354,43 @@ def test_hash(self, attribute):
         details = simple.from_project_details_html(html, "test_default")
         assert len(details["files"]) == 1
         assert details["files"][0]["dist-info-metadata"] == {"sha256": "abcdef"}
+
+
+class TestParseProjectIndex:
+    def test_json(self):
+        index = simple.parse_project_index(INDEX_v1_EXAMPLE, simple.ACCEPT_JSON_V1)
+        assert index == json.loads(INDEX_v1_EXAMPLE)
+
+    @pytest.mark.parametrize(
+        ["content_type"],
+        [(content_type,) for content_type in simple._ACCEPT_HTML_VALUES],
+    )
+    def test_html(self, content_type):
+        index = simple.parse_project_index(INDEX_HTML_EXAMPLE, content_type)
+        assert index == json.loads(INDEX_v1_EXAMPLE)
+
+    def test_invalid_content_type(self):
+        with pytest.raises(simple.UnsupportedMIMEType):
+            simple.parse_project_index(INDEX_HTML_EXAMPLE, "invalid")
+
+
+class TestParseProjectDetails:
+    def test_json(self):
+        index = simple.parse_project_details(
+            DETAILS_V1_EXAMPLE, simple.ACCEPT_JSON_V1, "holygrail"
+        )
+        assert index == json.loads(DETAILS_V1_EXAMPLE)
+
+    @pytest.mark.parametrize(
+        ["content_type"],
+        [(content_type,) for content_type in simple._ACCEPT_HTML_VALUES],
+    )
+    def test_html(self, content_type):
+        index = simple.parse_project_details(
+            DETAILS_HTML_EXAMPLE, content_type, "holygrail"
+        )
+        assert index == json.loads(DETAILS_V1_EXAMPLE)
+
+    def test_invalid_content_type(self):
+        with pytest.raises(simple.UnsupportedMIMEType):
+            simple.parse_project_details(INDEX_HTML_EXAMPLE, "invalid", "holygrail")