Skip to content

Commit

Permalink
Merge pull request #27 from jwodder/errors
Browse files Browse the repository at this point in the history
Add `url` fields to various exception types
  • Loading branch information
jwodder authored Jul 18, 2024
2 parents ed84451 + 04439cd commit 9a9098d
Show file tree
Hide file tree
Showing 7 changed files with 137 additions and 65 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ v1.6.0 (in development)
`DistributionPackage`
- `get_provenance()` method added to `PyPISimple`
- `NoProvenanceError` exception type added
- Add `url` fields to the `DigestMismatchError`, `NoDigestsError`, and
`NoMetadataError` classes

v1.5.0 (2024-02-24)
-------------------
Expand Down
3 changes: 3 additions & 0 deletions docs/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ v1.6.0 (in development)
- `~PyPISimple.get_provenance()` method added to `PyPISimple`
- `NoProvenanceError` exception type added

- Add ``url`` fields to the `DigestMismatchError`, `NoDigestsError`, and
`NoMetadataError` classes


v1.5.0 (2024-02-24)
-------------------
Expand Down
5 changes: 4 additions & 1 deletion src/pypi_simple/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,13 @@
)

from .classes import DistributionPackage, IndexPage, ProjectPage
from .client import NoMetadataError, NoProvenanceError, NoSuchProjectError, PyPISimple
from .client import PyPISimple
from .errors import (
DigestMismatchError,
NoDigestsError,
NoMetadataError,
NoProvenanceError,
NoSuchProjectError,
UnexpectedRepoVersionWarning,
UnparsableFilenameError,
UnsupportedContentTypeError,
Expand Down
65 changes: 11 additions & 54 deletions src/pypi_simple/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,12 @@
import requests
from . import ACCEPT_ANY, PYPI_SIMPLE_ENDPOINT, __url__, __version__
from .classes import DistributionPackage, IndexPage, ProjectPage
from .errors import UnsupportedContentTypeError
from .errors import (
NoMetadataError,
NoProvenanceError,
NoSuchProjectError,
UnsupportedContentTypeError,
)
from .html_stream import parse_links_stream_response
from .progress import ProgressTracker, null_progress_tracker
from .util import AbstractDigestChecker, DigestChecker, NullDigestChecker
Expand Down Expand Up @@ -341,7 +346,7 @@ def download_package(
target.parent.mkdir(parents=True, exist_ok=True)
digester: AbstractDigestChecker
if verify:
digester = DigestChecker(pkg.digests)
digester = DigestChecker(pkg.digests, pkg.url)
else:
digester = NullDigestChecker()
with self.s.get(pkg.url, stream=True, timeout=timeout, headers=headers) as r:
Expand Down Expand Up @@ -414,12 +419,12 @@ def get_package_metadata_bytes(
"""
digester: AbstractDigestChecker
if verify:
digester = DigestChecker(pkg.metadata_digests or {})
digester = DigestChecker(pkg.metadata_digests or {}, pkg.metadata_url)
else:
digester = NullDigestChecker()
r = self.s.get(pkg.metadata_url, timeout=timeout, headers=headers)
if r.status_code == 404:
raise NoMetadataError(pkg.filename)
raise NoMetadataError(pkg.filename, pkg.metadata_url)
r.raise_for_status()
digester.update(r.content)
digester.finalize()
Expand Down Expand Up @@ -530,61 +535,13 @@ def get_provenance(
digests = {"sha256": pkg.provenance_sha256}
else:
digests = {}
digester = DigestChecker(digests)
digester = DigestChecker(digests, pkg.provenance_url)
else:
digester = NullDigestChecker()
r = self.s.get(pkg.provenance_url, timeout=timeout, headers=headers)
if r.status_code == 404:
raise NoProvenanceError(pkg.filename)
raise NoProvenanceError(pkg.filename, pkg.provenance_url)
r.raise_for_status()
digester.update(r.content)
digester.finalize()
return json.loads(r.content) # type: ignore[no-any-return]


class NoSuchProjectError(Exception):
"""
Raised by `PyPISimple.get_project_page()` when a request for a project
fails with a 404 error code
"""

def __init__(self, project: str, url: str) -> None:
#: The name of the project requested
self.project = project
#: The URL to which the failed request was made
self.url = url

def __str__(self) -> str:
return f"No details about project {self.project!r} available at {self.url}"


class NoMetadataError(Exception):
"""
.. versionadded:: 1.3.0
Raised by `PyPISimple.get_package_metadata()` when a request for
distribution metadata fails with a 404 error code
"""

def __init__(self, filename: str) -> None:
#: The filename of the package whose metadata was requested
self.filename = filename

def __str__(self) -> str:
return f"No distribution metadata found for {self.filename}"


class NoProvenanceError(Exception):
"""
.. versionadded:: 1.6.0
Raised by `PyPISimple.get_provenance()` when a request for a
``.provenance`` file fails with a 404 error code
"""

def __init__(self, filename: str) -> None:
#: The filename of the package whose provenance was requested
self.filename = filename

def __str__(self) -> str:
return f"No .provenance file found for {self.filename}"
73 changes: 69 additions & 4 deletions src/pypi_simple/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,14 @@ class NoDigestsError(ValueError):
algorithms
"""

pass
def __init__(self, url: str) -> None:
#: The URL of the resource being downloaded
#:
#: .. versionadded:: 1.6.0
self.url = url

def __str__(self) -> str:
return f"No digests with known algorithms available for resource at {self.url}"


class DigestMismatchError(ValueError):
Expand All @@ -67,19 +74,23 @@ class DigestMismatchError(ValueError):
"""

def __init__(
self, algorithm: str, expected_digest: str, actual_digest: str
self, *, algorithm: str, expected_digest: str, actual_digest: str, url: str
) -> None:
#: The name of the digest algorithm used
self.algorithm = algorithm
#: The expected digest
self.expected_digest = expected_digest
#: The digest of the data that was actually received
self.actual_digest = actual_digest
#: The URL of the resource being downloaded
#:
#: .. versionadded:: 1.6.0
self.url = url

def __str__(self) -> str:
return (
f"{self.algorithm} digest of downloaded data is"
f" {self.actual_digest!r} instead of expected {self.expected_digest!r}"
f"{self.algorithm} digest of {self.url} is {self.actual_digest!r}"
f" instead of expected {self.expected_digest!r}"
)


Expand All @@ -96,3 +107,57 @@ def __init__(self, filename: str) -> None:

def __str__(self) -> str:
return f"Cannot parse package filename: {self.filename!r}"


class NoSuchProjectError(Exception):
"""
Raised by `PyPISimple.get_project_page()` when a request for a project
fails with a 404 error code
"""

def __init__(self, project: str, url: str) -> None:
#: The name of the project requested
self.project = project
#: The URL to which the failed request was made
self.url = url

def __str__(self) -> str:
return f"No details about project {self.project!r} available at {self.url}"


class NoMetadataError(Exception):
"""
.. versionadded:: 1.3.0
Raised by `PyPISimple.get_package_metadata()` when a request for
distribution metadata fails with a 404 error code
"""

def __init__(self, filename: str, url: str) -> None:
#: The filename of the package whose metadata was requested
self.filename = filename
#: The URL to which the failed request was made
#:
#: .. versionadded:: 1.6.0
self.url = url

def __str__(self) -> str:
return f"No distribution metadata found for {self.filename} at {self.url}"


class NoProvenanceError(Exception):
"""
.. versionadded:: 1.6.0
Raised by `PyPISimple.get_provenance()` when a request for a
``.provenance`` file fails with a 404 error code
"""

def __init__(self, filename: str, url: str) -> None:
#: The filename of the package whose provenance was requested
self.filename = filename
#: The URL to which the failed request was made
self.url = url

def __str__(self) -> str:
return f"No .provenance file found for {self.filename} at {self.url}"
6 changes: 4 additions & 2 deletions src/pypi_simple/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,10 @@ def finalize(self) -> None:


class DigestChecker(AbstractDigestChecker):
def __init__(self, digests: dict[str, str]) -> None:
def __init__(self, digests: dict[str, str], url: str) -> None:
self.digesters: dict[str, Any] = {}
self.expected: dict[str, str] = {}
self.url = url
for alg, value in digests.items():
try:
d = hashlib.new(alg)
Expand All @@ -73,7 +74,7 @@ def __init__(self, digests: dict[str, str]) -> None:
self.digesters[alg] = d
self.expected[alg] = value
if not self.digesters:
raise NoDigestsError("No digests with known algorithms available")
raise NoDigestsError(self.url)

def update(self, blob: bytes) -> None:
for d in self.digesters.values():
Expand All @@ -87,6 +88,7 @@ def finalize(self) -> None:
algorithm=alg,
expected_digest=self.expected[alg],
actual_digest=actual,
url=self.url,
)


Expand Down
48 changes: 44 additions & 4 deletions test/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
DistributionPackage,
IndexPage,
NoDigestsError,
NoMetadataError,
NoProvenanceError,
NoSuchProjectError,
ProgressTracker,
Expand Down Expand Up @@ -667,7 +668,10 @@ def test_download_no_digests(tmp_path: Path) -> None:
dest = tmp_path / str(pkg.project) / pkg.filename
with pytest.raises(NoDigestsError) as excinfo:
simple.download_package(pkg, dest)
assert str(excinfo.value) == "No digests with known algorithms available"
assert (
str(excinfo.value)
== "No digests with known algorithms available for resource at https://test.nil/simple/packages/click_loglevel-0.4.0.post1-py3-none-any.whl"
)
assert not dest.exists()


Expand Down Expand Up @@ -700,7 +704,7 @@ def test_download_bad_digests(tmp_path: Path) -> None:
with pytest.raises(DigestMismatchError) as excinfo:
simple.download_package(pkg, dest)
assert str(excinfo.value) == (
"sha256 digest of downloaded data is"
"sha256 digest of https://test.nil/simple/packages/click_loglevel-0.4.0.post1-py3-none-any.whl is"
" '17e88db187afd62c16e5debf3e6527cd006bc012bc90b51a810cd80c2d511f43'"
" instead of expected"
" 'f3449b5d28d6cba5bfbeed371ad59950aba035730d5cc28a32b4e7632e17ed6c'"
Expand Down Expand Up @@ -737,7 +741,7 @@ def test_download_bad_digests_keep(tmp_path: Path) -> None:
with pytest.raises(DigestMismatchError) as excinfo:
simple.download_package(pkg, dest, keep_on_error=True)
assert str(excinfo.value) == (
"sha256 digest of downloaded data is"
"sha256 digest of https://test.nil/simple/packages/click_loglevel-0.4.0.post1-py3-none-any.whl is"
" '17e88db187afd62c16e5debf3e6527cd006bc012bc90b51a810cd80c2d511f43'"
" instead of expected"
" 'f3449b5d28d6cba5bfbeed371ad59950aba035730d5cc28a32b4e7632e17ed6c'"
Expand Down Expand Up @@ -891,6 +895,38 @@ def test_metadata_encoding() -> None:
assert simple.get_package_metadata(pkg) == "\udcff\udcfe\u0003\u0026"


@responses.activate
def test_metadata_404() -> None:
responses.add(
method=responses.GET,
url="https://test.nil/simple/packages/sampleproject-1.2.3-py3-none-any.whl.metadata",
body="Does not exist",
status=404,
)
with PyPISimple("https://test.nil/simple/") as simple:
pkg = DistributionPackage(
filename="sampleproject-1.2.3-py3-none-any.whl",
project="sampleproject",
version="1.2.3",
package_type="wheel",
url="https://test.nil/simple/packages/sampleproject-1.2.3-py3-none-any.whl",
digests={},
requires_python=None,
has_sig=None,
)
with pytest.raises(NoMetadataError) as excinfo:
simple.get_package_metadata(pkg, verify=False)
assert excinfo.value.filename == "sampleproject-1.2.3-py3-none-any.whl"
assert (
excinfo.value.url
== "https://test.nil/simple/packages/sampleproject-1.2.3-py3-none-any.whl.metadata"
)
assert (
str(excinfo.value)
== "No distribution metadata found for sampleproject-1.2.3-py3-none-any.whl at https://test.nil/simple/packages/sampleproject-1.2.3-py3-none-any.whl.metadata"
)


@responses.activate
def test_custom_headers_get_index_page() -> None:
with (DATA_DIR / "simple01.html").open() as fp:
Expand Down Expand Up @@ -1008,9 +1044,13 @@ def test_get_provenance_404() -> None:
with pytest.raises(NoProvenanceError) as excinfo:
simple.get_provenance(pkg, verify=False)
assert excinfo.value.filename == "sampleproject-1.2.3-py3-none-any.whl"
assert (
excinfo.value.url
== "https://test.nil/simple/packages/sampleproject-1.2.3-py3-none-any.whl.provenance"
)
assert (
str(excinfo.value)
== "No .provenance file found for sampleproject-1.2.3-py3-none-any.whl"
== "No .provenance file found for sampleproject-1.2.3-py3-none-any.whl at https://test.nil/simple/packages/sampleproject-1.2.3-py3-none-any.whl.provenance"
)


Expand Down

0 comments on commit 9a9098d

Please sign in to comment.