Skip to content

Commit

Permalink
Merge pull request #26 from jwodder/pep740
Browse files Browse the repository at this point in the history
Support PEP 740
  • Loading branch information
jwodder authored Jul 18, 2024
2 parents 974d80e + 1cf2688 commit ed84451
Show file tree
Hide file tree
Showing 13 changed files with 280 additions and 18 deletions.
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
v1.6.0 (in development)
-----------------------
- Drop support for Python 3.7
- Support PEP 740
- `provenance_sha256` and `provenance_url` fields added to
`DistributionPackage`
- `get_provenance()` method added to `PyPISimple`
- `NoProvenanceError` exception type added

v1.5.0 (2024-02-24)
-------------------
Expand Down
4 changes: 2 additions & 2 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@
``pypi-simple`` is a client library for the Python Simple Repository API as
specified in :pep:`503` and updated by :pep:`592`, :pep:`629`, :pep:`658`,
:pep:`691`, :pep:`700`, :pep:`708`, and :pep:`714`. With it, you can query
`the Python Package Index (PyPI) <https://pypi.org>`_ and other `pip
:pep:`691`, :pep:`700`, :pep:`708`, :pep:`714`, and :pep:`740`. With it, you
can query `the Python Package Index (PyPI) <https://pypi.org>`_ and other `pip
<https://pip.pypa.io>`_-compatible repositories for a list of their available
projects and lists of each project's available package files. The library also
allows you to download package files and query them for their project version,
Expand Down
1 change: 1 addition & 0 deletions docs/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ Exceptions
.. autoexception:: NoDigestsError()
:show-inheritance:
.. autoexception:: NoMetadataError()
.. autoexception:: NoProvenanceError()
.. autoexception:: NoSuchProjectError()
.. autoexception:: UnsupportedContentTypeError()
:show-inheritance:
Expand Down
8 changes: 8 additions & 0 deletions docs/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ v1.6.0 (in development)
-----------------------
- Drop support for Python 3.7

- Support :pep:`740`

- `~DistributionPackage.provenance_sha256` and
`~DistributionPackage.provenance_url` fields added to
`DistributionPackage`
- `~PyPISimple.get_provenance()` method added to `PyPISimple`
- `NoProvenanceError` exception type added


v1.5.0 (2024-02-24)
-------------------
Expand Down
4 changes: 2 additions & 2 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ pypi-simple — PyPI Simple Repository API client library

``pypi-simple`` is a client library for the Python Simple Repository API as
specified in :pep:`503` and updated by :pep:`592`, :pep:`629`, :pep:`658`,
:pep:`691`, :pep:`700`, :pep:`708`, and :pep:`714`. With it, you can query
`the Python Package Index (PyPI) <https://pypi.org>`_ and other `pip
:pep:`691`, :pep:`700`, :pep:`708`, :pep:`714`, and :pep:`740`. With it, you
can query `the Python Package Index (PyPI) <https://pypi.org>`_ and other `pip
<https://pip.pypa.io>`_-compatible repositories for a list of their available
projects and lists of each project's available package files. The library also
allows you to download package files and query them for their project version,
Expand Down
3 changes: 2 additions & 1 deletion src/pypi_simple/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@
)

from .classes import DistributionPackage, IndexPage, ProjectPage
from .client import NoMetadataError, NoSuchProjectError, PyPISimple
from .client import NoMetadataError, NoProvenanceError, NoSuchProjectError, PyPISimple
from .errors import (
DigestMismatchError,
NoDigestsError,
Expand All @@ -89,6 +89,7 @@
"Link",
"NoDigestsError",
"NoMetadataError",
"NoProvenanceError",
"NoSuchProjectError",
"PYPI_SIMPLE_ENDPOINT",
"ProgressTracker",
Expand Down
28 changes: 23 additions & 5 deletions src/pypi_simple/classes.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from .filenames import parse_filename
from .html import Link, RepositoryPage
from .pep691 import File, Project, ProjectList
from .util import basejoin, check_repo_version
from .util import basejoin, check_repo_version, url_add_suffix


@dataclass
Expand Down Expand Up @@ -94,23 +94,39 @@ class DistributionPackage:
#: if not specified [#pep700]_.
upload_time: Optional[datetime] = None

#: .. versionadded:: 1.6.0
#:
#: The SHA 256 digest of the package file's :pep:`740` ``.provenance``
#: file.
#:
#: If `provenance_sha256` is non-`None`, then the package repository
#: provides a ``.provenance`` file for the package. If it is `None`, no
#: conclusions can be drawn.
provenance_sha256: Optional[str] = None

@property
def sig_url(self) -> str:
"""
The URL of the package file's PGP signature file, if it exists; cf.
`has_sig`
"""
u = urlparse(self.url)
return urlunparse((u[0], u[1], u[2] + ".asc", "", "", ""))
return url_add_suffix(self.url, ".asc")

@property
def metadata_url(self) -> str:
"""
The URL of the package file's Core Metadata file, if it exists; cf.
`has_metadata`
"""
u = urlparse(self.url)
return urlunparse((u[0], u[1], u[2] + ".metadata", "", "", ""))
return url_add_suffix(self.url, ".metadata")

@property
def provenance_url(self) -> str:
"""
The URL of the package file's :pep:`740` ``.provenance`` file, if it
exists; cf. `provenance_sha256`
"""
return url_add_suffix(self.url, ".provenance")

@classmethod
def from_link(
Expand Down Expand Up @@ -166,6 +182,7 @@ def from_link(
digests=digests,
metadata_digests=metadata_digests,
has_metadata=has_metadata,
provenance_sha256=link.get_str_attrib("data-provenance"),
)

@classmethod
Expand Down Expand Up @@ -219,6 +236,7 @@ def from_file(
has_metadata=file.has_metadata,
size=file.size,
upload_time=file.upload_time,
provenance_sha256=file.provenance,
)


Expand Down
74 changes: 74 additions & 0 deletions src/pypi_simple/client.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations
from collections.abc import Callable, Iterator
import json
import os
from pathlib import Path
import platform
Expand Down Expand Up @@ -483,6 +484,63 @@ def get_package_metadata(
headers,
).decode("utf-8", "surrogateescape")

def get_provenance(
self,
pkg: DistributionPackage,
verify: bool = True,
timeout: float | tuple[float, float] | None = None,
headers: Optional[dict[str, str]] = None,
) -> dict[str, Any]:
"""
.. versionadded:: 1.6.0
Retrieve the :pep:`740` ``.provenance`` file for the given
`DistributionPackage` and decode it as JSON.
Not all packages have ``.provenance`` files available for download; cf.
`DistributionPackage.provenance_sha256`. This method will always
attempt to download the ``.provenance`` file regardless of the value of
`DistributionPackage.provenance_sha256`; if the server replies with a
404, a `NoProvenanceError` is raised.
:param DistributionPackage pkg:
the distribution package to retrieve the ``.provenance`` file of
:param bool verify:
whether to verify the ``.provenance`` file's SHA 256 digest against
the retrieved data
:param timeout: optional timeout to pass to the ``requests`` call
:type timeout: float | tuple[float,float] | None
:param Optional[dict[str, str]] headers:
Custom headers to provide for the request.
:rtype: dict[str, Any]
:raises NoProvenanceError:
if the repository responds with a 404 error code
:raises requests.HTTPError: if the repository responds with an HTTP
error code other than 404
:raises NoDigestsError:
if ``verify`` is true and ``pkg.provenance_sha256`` is `None`
:raises DigestMismatchError:
if ``verify`` is true and the digest of the downloaded data does
not match the expected value
"""
digester: AbstractDigestChecker
if verify:
if pkg.provenance_sha256 is not None:
digests = {"sha256": pkg.provenance_sha256}
else:
digests = {}
digester = DigestChecker(digests)
else:
digester = NullDigestChecker()
r = self.s.get(pkg.provenance_url, timeout=timeout, headers=headers)
if r.status_code == 404:
raise NoProvenanceError(pkg.filename)
r.raise_for_status()
digester.update(r.content)
digester.finalize()
return json.loads(r.content) # type: ignore[no-any-return]


class NoSuchProjectError(Exception):
"""
Expand Down Expand Up @@ -514,3 +572,19 @@ def __init__(self, filename: str) -> None:

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}"
11 changes: 5 additions & 6 deletions src/pypi_simple/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,19 +52,18 @@ def __str__(self) -> str:

class NoDigestsError(ValueError):
"""
Raised by `PyPISimple.download_package()` and
`PyPISimple.get_package_metadata()` with ``verify=True`` when the given
package or package metadata does not have any digests with known algorithms
Raised by `PyPISimple`'s download methods when passed ``verify=True`` and
the resource being downloaded does not have any digests with known
algorithms
"""

pass


class DigestMismatchError(ValueError):
"""
Raised by `PyPISimple.download_package()` and
`PyPISimple.get_package_metadata()` with ``verify=True`` when the digest of
the downloaded data does not match the expected value
Raised by `PyPISimple`'s download methods when passed ``verify=True`` and
the digest of the downloaded data does not match the expected value
"""

def __init__(
Expand Down
1 change: 1 addition & 0 deletions src/pypi_simple/pep691.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ class File(BaseModel, alias_generator=shishkebab, populate_by_name=True):
yanked: Union[StrictBool, str] = False
size: Optional[int] = None
upload_time: Optional[datetime] = None
provenance: Optional[str] = None

@property
def is_yanked(self) -> bool:
Expand Down
11 changes: 10 additions & 1 deletion src/pypi_simple/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from abc import ABC, abstractmethod
import hashlib
from typing import Any, Optional
from urllib.parse import urljoin
from urllib.parse import urljoin, urlparse, urlunparse
import warnings
from packaging.version import Version
from . import SUPPORTED_REPOSITORY_VERSION
Expand Down Expand Up @@ -88,3 +88,12 @@ def finalize(self) -> None:
expected_digest=self.expected[alg],
actual_digest=actual,
)


def url_add_suffix(url: str, suffix: str) -> str:
"""
Append `suffix` to the path portion of the URL `url`. Any query parameters
or fragments on the URL are discarded.
"""
u = urlparse(url)
return urlunparse((u[0], u[1], u[2] + suffix, "", "", ""))
Loading

0 comments on commit ed84451

Please sign in to comment.