Skip to content

Commit

Permalink
Fix file attributes
Browse files Browse the repository at this point in the history
* HTML/JSON response hashes
* Importantly, HTML response is-yanked attribute
  • Loading branch information
EpicWink committed Aug 8, 2022
1 parent e104bb1 commit 394b390
Show file tree
Hide file tree
Showing 2 changed files with 91 additions and 36 deletions.
73 changes: 48 additions & 25 deletions src/proxpi/_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,17 +81,17 @@ def requires_python(self) -> t.Union[str, None]:

@property
@abc.abstractmethod
def dist_info_metadata(self) -> t.Union[str, None]:
def dist_info_metadata(self) -> t.Union[bool, t.Dict[str, str], None]:
"""Distribution metadata file marker."""

@property
@abc.abstractmethod
def gpg_sig(self) -> t.Union[str, None]:
def gpg_sig(self) -> t.Union[bool, None]:
"""Distribution GPG signature file marker."""

@property
@abc.abstractmethod
def yanked(self) -> t.Union[str, None]:
def yanked(self) -> t.Union[bool, str, None]:
"""File yanked status."""

def to_json_response(self) -> t.Dict[str, t.Any]:
Expand Down Expand Up @@ -132,30 +132,40 @@ def from_html_element(

@property
def hashes(self):
hashes = {}
for part in self.fragment.split(","):
try:
hash_name, hash_value = part.split("=")
except ValueError:
continue
hashes[hash_name] = hash_value
return hashes
return self._parse_hash(self.fragment)

@property
def requires_python(self):
return self.attributes.get(f"data-requires-python")
return self.attributes.get("data-requires-python") or None

@property
def dist_info_metadata(self):
return self.attributes.get(f"data-dist-info-metadata")
metadata = self.attributes.get("data-dist-info-metadata")
if metadata is None:
return None
hashes = self._parse_hash(metadata)
if not hashes:
return True # '': value-less -> true
return hashes

@property
def gpg_sig(self):
return self.attributes.get(f"data-gpg-sig")
has_gpg_sig = self.attributes.get("data-gpg-sig")
return has_gpg_sig and self.attributes.get("data-gpg-sig") == "true"

@property
def yanked(self):
return self.attributes.get(f"data-yanked")
return self.attributes.get("data-yanked") is not None # '': value-less -> true

@staticmethod
def _parse_hash(hash_string: str) -> t.Dict[str, str]:
try:
hash_name, hash_value = hash_string.split("=")
except ValueError:
if hash_string.count("=") > 0:
raise
return {}
return {hash_name: hash_value}


@dataclasses.dataclass
Expand All @@ -174,9 +184,9 @@ class FileFromJSON(File):
url: str
hashes: t.Dict[str, str]
requires_python: t.Union[str, None]
dist_info_metadata: t.Union[str, None]
gpg_sig: t.Union[str, None]
yanked: t.Union[str, None]
dist_info_metadata: t.Union[bool, t.Dict[str, str], None]
gpg_sig: t.Union[bool, None]
yanked: t.Union[bool, str, None]

@classmethod
def from_json_response(cls, data: t.Dict[str, t.Any], request_url: str) -> "File":
Expand All @@ -194,22 +204,35 @@ def from_json_response(cls, data: t.Dict[str, t.Any], request_url: str) -> "File
@property
def fragment(self) -> str:
"""File URL fragment."""
return ",".join(f"{n}={v}" for n, v in self.hashes.items())
return self._stringify_hashes(self.hashes)

@property
def attributes(self) -> t.Dict[str, str]:
"""File reference link element (non-href) attributes."""
attributes = {}
if self.requires_python is not None:
if self.requires_python:
attributes["data-requires-python"] = self.requires_python
if self.dist_info_metadata is not None:
attributes["data-dist-info-metadata"] = self.dist_info_metadata
if self.dist_info_metadata:
attributes["data-dist-info-metadata"] = self._stringify_hashes(
self.dist_info_metadata,
) if isinstance(self.dist_info_metadata, dict) else "" # fmt: skip
if self.gpg_sig is not None:
attributes["data-gpg-sig"] = self.gpg_sig
if self.yanked is not None:
attributes["data-yanked"] = self.yanked
attributes["data-gpg-sig"] = "true" if self.gpg_sig else "false"
if self.yanked:
attributes["data-yanked"] = (
self.yanked if isinstance(self.yanked, str) else ""
)
return attributes

@staticmethod
def _stringify_hashes(hashes: t.Dict[str, str]) -> str:
if not hashes:
return ""
if "sha256" in hashes:
return f"sha256={hashes['sha256']}"
for hash_name, hash_value in hashes.items():
return f"{hash_name}={hash_value}"


@dataclasses.dataclass
class Package:
Expand Down
54 changes: 43 additions & 11 deletions tests/test_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@


@contextlib.contextmanager
def set_mock_index_response_is_json(value: bool):
def set_mock_index_response_is_json(value: t.Union[bool, str]):
global mock_index_response_is_json

original_mock_index_response_is_json = mock_index_response_is_json
Expand Down Expand Up @@ -99,6 +99,11 @@ def get_project(name: str) -> t.Union[str, flask.Response]:
for file in files:
file_data = file.to_json_response()
file_data["url"] = file.name
if (
mock_index_response_is_json == "yanked"
and not file_data.get("yanked")
):
file_data["yanked"] = False
files_data.append(file_data)
return build_json_v1_response({
"meta": {"api-version": "1.0"},
Expand Down Expand Up @@ -143,7 +148,7 @@ def mock_root_index():
name="proxpi-1.0.0.tar.gz",
url="foo bar 42",
fragment="",
attributes={},
attributes={"data-requires-python": ""},
),
],
"numpy": [
Expand All @@ -157,7 +162,7 @@ def mock_root_index():
name="numpy-1.23.1-cp310-cp310-win_amd64.whl",
url="",
fragment="sha256=",
attributes={"data-requires-python": ">=3.8"},
attributes={"data-requires-python": ">=3.8", "data-yanked": ""},
),
File(
name="numpy-1.23.1.tar.gz",
Expand Down Expand Up @@ -269,7 +274,7 @@ def test_list_json(server, accept, index_json_response):
@pytest.mark.parametrize("accept", [
"text/html", "application/vnd.pypi.simple.v1+html", "*/*"
])
@pytest.mark.parametrize("index_json_response", [False, True])
@pytest.mark.parametrize("index_json_response", [False, True, "yanked"])
def test_package(server, project, accept, index_json_response):
"""Test getting package files."""
project_url = f"{server}/index/{project}/"
Expand Down Expand Up @@ -328,14 +333,28 @@ def test_package(server, project, accept, index_json_response):
specifier = packaging.specifiers.SpecifierSet(python_requirement)
assert specifier.filter(["1.2", "2.7", "3.3", "3.7", "3.10", "3.12"])

attributes_by_filename = dict(parser.anchors)
if project == "proxpi":
attributes = attributes_by_filename["proxpi-1.0.0.tar.gz"]
assert not any(v for k, v in attributes if k == "data-requires-python")

elif project == "numpy":
assert any(k == "data-yanked" for k, _ in attributes_by_filename.pop(
"numpy-1.23.1-cp310-cp310-win_amd64.whl",
)) # fmt: skip

for filename, attributes in attributes_by_filename.items():
assert not any(k == "data-yanked" for k, _ in attributes), attributes


@pytest.mark.parametrize("project", ["proxpi", "numpy", "scipy"])
@pytest.mark.parametrize("accept", [
"application/vnd.pypi.simple.v1+json",
"application/vnd.pypi.simple.latest+json",
])
@pytest.mark.parametrize("query_format", [False, True])
@pytest.mark.parametrize("index_json_response", [False, True])
def test_package_json(server, accept, query_format, index_json_response):
@pytest.mark.parametrize("index_json_response", [False, True, "yanked"])
def test_package_json(server, project, accept, query_format, index_json_response):
"""Test getting package files with JSON API."""
params = None
headers = None
Expand All @@ -345,7 +364,7 @@ def test_package_json(server, accept, query_format, index_json_response):
headers = {"Accept": accept}
with set_mock_index_response_is_json(index_json_response):
response = requests.get(
f"{server}/index/proxpi/", params=params, headers=headers
f"{server}/index/{project}/", params=params, headers=headers
)

assert response.status_code == 200
Expand All @@ -356,10 +375,23 @@ def test_package_json(server, accept, query_format, index_json_response):
assert "Accept-Encoding" in vary
assert "Accept" in vary

assert response.json()["meta"] == {"api-version": "1.0"}
assert response.json()["name"] == "proxpi"
assert all(f["url"] and f["filename"] == f["url"] for f in response.json()["files"])
assert all("hashes" in f for f in response.json()["files"])
response_data = response.json()
assert response_data["meta"] == {"api-version": "1.0"}
assert response_data["name"] == project

for file in response_data["files"]:
assert file["url"]
assert file["filename"] == file["url"]
assert isinstance(file["hashes"], dict)

files_by_filename = {f["filename"]: f for f in response_data["files"]}
if project == "proxpi":
assert not files_by_filename["proxpi-1.0.0.tar.gz"].get("requires-python")

elif project == "numpy":
yanked_file = files_by_filename.pop("numpy-1.23.1-cp310-cp310-win_amd64.whl")
assert yanked_file.get("yanked")
assert not any(f.get("yanked") for f in files_by_filename.values())


def test_package_unknown_accept(server):
Expand Down

0 comments on commit 394b390

Please sign in to comment.