Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Provide support code for making HTTP requests and processing the responses #89

Merged
merged 4 commits into from
Dec 14, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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")