diff --git a/src/proxpi/_cache.py b/src/proxpi/_cache.py index 7610e49..cc59d62 100644 --- a/src/proxpi/_cache.py +++ b/src/proxpi/_cache.py @@ -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]: @@ -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 @@ -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": @@ -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: diff --git a/tests/test_integration.py b/tests/test_integration.py index 1d9f8dc..c223824 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -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 @@ -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"}, @@ -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": [ @@ -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", @@ -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}/" @@ -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 @@ -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 @@ -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):