Skip to content

Commit

Permalink
Provide support code for making HTTP requests and processing the resp…
Browse files Browse the repository at this point in the history
…onses (#89)

Closes #75
Closes #77
  • Loading branch information
brettcannon authored Dec 14, 2022
1 parent 85798b0 commit 914489e
Show file tree
Hide file tree
Showing 5 changed files with 190 additions and 3 deletions.
2 changes: 1 addition & 1 deletion docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---------------------------------------------------
Expand Down
28 changes: 28 additions & 0 deletions docs/simple.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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
67 changes: 67 additions & 0 deletions mousebender/simple.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

import html
import html.parser
import json
import urllib.parse
from typing import Any, Dict, List, Optional, Union

Expand All @@ -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"]})


Expand Down Expand Up @@ -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}")
8 changes: 6 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
88 changes: 88 additions & 0 deletions tests/test_simple.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,59 @@
"""Tests for mousebender.simple."""
import json

import importlib_resources
import pytest

from mousebender import simple

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"])
Expand Down Expand Up @@ -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")

0 comments on commit 914489e

Please sign in to comment.