pypi-simple
Advanced tools
+11
-0
@@ -0,1 +1,12 @@ | ||
| v1.6.0 (2024-07-18) | ||
| ------------------- | ||
| - 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 | ||
| - Add `url` fields to the `DigestMismatchError`, `NoDigestsError`, and | ||
| `NoMetadataError` classes | ||
| v1.5.0 (2024-02-24) | ||
@@ -2,0 +13,0 @@ ------------------- |
+1
-0
@@ -61,2 +61,3 @@ .. currentmodule:: pypi_simple | ||
| .. autoexception:: NoMetadataError() | ||
| .. autoexception:: NoProvenanceError() | ||
| .. autoexception:: NoSuchProjectError() | ||
@@ -63,0 +64,0 @@ .. autoexception:: UnsupportedContentTypeError() |
+16
-0
@@ -6,2 +6,18 @@ .. currentmodule:: pypi_simple | ||
| v1.6.0 (2024-07-18) | ||
| ------------------- | ||
| - 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 | ||
| - Add ``url`` fields to the `DigestMismatchError`, `NoDigestsError`, and | ||
| `NoMetadataError` classes | ||
| v1.5.0 (2024-02-24) | ||
@@ -8,0 +24,0 @@ ------------------- |
+14
-11
@@ -18,8 +18,7 @@ Examples | ||
| # Requirements: | ||
| # Python 3.8+ | ||
| # packaging 23.1+ | ||
| # pypi_simple 1.3+ | ||
| # pypi_simple 1.5+ | ||
| from packaging.metadata import parse_email | ||
| from pypi_simple import PyPISimple | ||
| from pypi_simple import NoMetadataError, PyPISimple | ||
@@ -29,11 +28,15 @@ with PyPISimple() as client: | ||
| for pkg in page.packages: | ||
| if pkg.has_metadata: | ||
| src = client.get_package_metadata(pkg) | ||
| md, _ = parse_email(src) | ||
| if deps := md.get("requires_dist"): | ||
| print(f"Dependencies for {pkg.filename}:") | ||
| for d in deps: | ||
| print(f" {d}") | ||
| if pkg.has_metadata is not False: | ||
| try: | ||
| src = client.get_package_metadata(pkg) | ||
| except NoMetadataError: | ||
| print(f"{pkg.filename}: No metadata available") | ||
| else: | ||
| print(f"Dependencies for {pkg.filename}: NONE") | ||
| md, _ = parse_email(src) | ||
| if deps := md.get("requires_dist"): | ||
| print(f"Dependencies for {pkg.filename}:") | ||
| for d in deps: | ||
| print(f" {d}") | ||
| else: | ||
| print(f"Dependencies for {pkg.filename}: NONE") | ||
| else: | ||
@@ -40,0 +43,0 @@ print(f"{pkg.filename}: No metadata available") |
+6
-5
@@ -22,4 +22,4 @@ .. module:: pypi_simple | ||
| 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 | ||
@@ -33,3 +33,3 @@ projects and lists of each project's available package files. The library also | ||
| ============ | ||
| ``pypi-simple`` requires Python 3.7 or higher. Just use `pip | ||
| ``pypi-simple`` requires Python 3.8 or higher. Just use `pip | ||
| <https://pip.pypa.io>`_ for Python 3 (You have pip, right?) to install it:: | ||
@@ -47,4 +47,4 @@ | ||
| Example | ||
| ======= | ||
| Examples | ||
| ======== | ||
@@ -83,2 +83,3 @@ Get information about a package: | ||
| :doc:`See more examples in the docs. <examples>` | ||
@@ -85,0 +86,0 @@ Indices and tables |
+19
-15
@@ -1,4 +0,4 @@ | ||
| Metadata-Version: 2.1 | ||
| Metadata-Version: 2.3 | ||
| Name: pypi-simple | ||
| Version: 1.5.0 | ||
| Version: 1.6.0 | ||
| Summary: PyPI Simple Repository API client library | ||
@@ -15,3 +15,2 @@ Project-URL: Source Code, https://github.com/jwodder/pypi-simple | ||
| Classifier: Programming Language :: Python :: 3 :: Only | ||
| Classifier: Programming Language :: Python :: 3.7 | ||
| Classifier: Programming Language :: Python :: 3.8 | ||
@@ -28,3 +27,3 @@ Classifier: Programming Language :: Python :: 3.9 | ||
| Classifier: Typing :: Typed | ||
| Requires-Python: >=3.7 | ||
| Requires-Python: >=3.8 | ||
| Requires-Dist: beautifulsoup4~=4.5 | ||
@@ -35,3 +34,2 @@ Requires-Dist: mailbits~=0.2 | ||
| Requires-Dist: requests~=2.20 | ||
| Requires-Dist: typing-extensions; python_version < '3.8' | ||
| Provides-Extra: tqdm | ||
@@ -41,3 +39,5 @@ Requires-Dist: tqdm; extra == 'tqdm' | ||
| .. image:: https://www.repostatus.org/badges/latest/active.svg | ||
| |repostatus| |ci-status| |coverage| |pyversions| |license| | ||
| .. |repostatus| image:: https://www.repostatus.org/badges/latest/active.svg | ||
| :target: https://www.repostatus.org/#active | ||
@@ -47,13 +47,13 @@ :alt: Project Status: Active — The project has reached a stable, usable | ||
| .. image:: https://github.com/jwodder/pypi-simple/actions/workflows/test.yml/badge.svg | ||
| .. |ci-status| image:: https://github.com/jwodder/pypi-simple/actions/workflows/test.yml/badge.svg | ||
| :target: https://github.com/jwodder/pypi-simple/actions/workflows/test.yml | ||
| :alt: CI Status | ||
| .. image:: https://codecov.io/gh/jwodder/pypi-simple/branch/master/graph/badge.svg | ||
| .. |coverage| image:: https://codecov.io/gh/jwodder/pypi-simple/branch/master/graph/badge.svg | ||
| :target: https://codecov.io/gh/jwodder/pypi-simple | ||
| .. image:: https://img.shields.io/pypi/pyversions/pypi-simple.svg | ||
| .. |pyversions| image:: https://img.shields.io/pypi/pyversions/pypi-simple.svg | ||
| :target: https://pypi.org/project/pypi-simple/ | ||
| .. image:: https://img.shields.io/github/license/jwodder/pypi-simple.svg | ||
| .. |license| image:: https://img.shields.io/github/license/jwodder/pypi-simple.svg | ||
| :target: https://opensource.org/licenses/MIT | ||
@@ -70,4 +70,4 @@ :alt: MIT License | ||
| 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 | ||
@@ -85,3 +85,3 @@ projects and lists of each project's available package files. The library also | ||
| ============ | ||
| ``pypi-simple`` requires Python 3.7 or higher. Just use `pip | ||
| ``pypi-simple`` requires Python 3.8 or higher. Just use `pip | ||
| <https://pip.pypa.io>`_ for Python 3 (You have pip, right?) to install it:: | ||
@@ -99,4 +99,4 @@ | ||
| Example | ||
| ======= | ||
| Examples | ||
| ======== | ||
@@ -134,1 +134,5 @@ Get information about a package: | ||
| ) | ||
| `See more examples in the docs.`__ | ||
| __ https://pypi-simple.readthedocs.io/en/stable/examples.html |
+1
-3
@@ -10,3 +10,3 @@ [build-system] | ||
| readme = "README.rst" | ||
| requires-python = ">=3.7" | ||
| requires-python = ">=3.8" | ||
| license = "MIT" | ||
@@ -30,3 +30,2 @@ license-files = { paths = ["LICENSE"] } | ||
| "Programming Language :: Python :: 3 :: Only", | ||
| "Programming Language :: Python :: 3.7", | ||
| "Programming Language :: Python :: 3.8", | ||
@@ -52,3 +51,2 @@ "Programming Language :: Python :: 3.9", | ||
| "requests ~= 2.20", | ||
| "typing_extensions; python_version < '3.8'", | ||
| ] | ||
@@ -55,0 +53,0 @@ |
+16
-10
@@ -1,2 +0,4 @@ | ||
| .. image:: https://www.repostatus.org/badges/latest/active.svg | ||
| |repostatus| |ci-status| |coverage| |pyversions| |license| | ||
| .. |repostatus| image:: https://www.repostatus.org/badges/latest/active.svg | ||
| :target: https://www.repostatus.org/#active | ||
@@ -6,13 +8,13 @@ :alt: Project Status: Active — The project has reached a stable, usable | ||
| .. image:: https://github.com/jwodder/pypi-simple/actions/workflows/test.yml/badge.svg | ||
| .. |ci-status| image:: https://github.com/jwodder/pypi-simple/actions/workflows/test.yml/badge.svg | ||
| :target: https://github.com/jwodder/pypi-simple/actions/workflows/test.yml | ||
| :alt: CI Status | ||
| .. image:: https://codecov.io/gh/jwodder/pypi-simple/branch/master/graph/badge.svg | ||
| .. |coverage| image:: https://codecov.io/gh/jwodder/pypi-simple/branch/master/graph/badge.svg | ||
| :target: https://codecov.io/gh/jwodder/pypi-simple | ||
| .. image:: https://img.shields.io/pypi/pyversions/pypi-simple.svg | ||
| .. |pyversions| image:: https://img.shields.io/pypi/pyversions/pypi-simple.svg | ||
| :target: https://pypi.org/project/pypi-simple/ | ||
| .. image:: https://img.shields.io/github/license/jwodder/pypi-simple.svg | ||
| .. |license| image:: https://img.shields.io/github/license/jwodder/pypi-simple.svg | ||
| :target: https://opensource.org/licenses/MIT | ||
@@ -29,4 +31,4 @@ :alt: MIT License | ||
| 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 | ||
@@ -44,3 +46,3 @@ projects and lists of each project's available package files. The library also | ||
| ============ | ||
| ``pypi-simple`` requires Python 3.7 or higher. Just use `pip | ||
| ``pypi-simple`` requires Python 3.8 or higher. Just use `pip | ||
| <https://pip.pypa.io>`_ for Python 3 (You have pip, right?) to install it:: | ||
@@ -58,4 +60,4 @@ | ||
| Example | ||
| ======= | ||
| Examples | ||
| ======== | ||
@@ -93,1 +95,5 @@ Get information about a package: | ||
| ) | ||
| `See more examples in the docs.`__ | ||
| __ https://pypi-simple.readthedocs.io/en/stable/examples.html |
@@ -17,3 +17,3 @@ """ | ||
| __version__ = "1.5.0" | ||
| __version__ = "1.6.0" | ||
| __author__ = "John Thorvald Wodder II" | ||
@@ -72,6 +72,9 @@ __author_email__ = "pypi-simple@varonathe.org" | ||
| from .classes import DistributionPackage, IndexPage, ProjectPage | ||
| from .client import NoMetadataError, NoSuchProjectError, PyPISimple | ||
| from .client import PyPISimple | ||
| from .errors import ( | ||
| DigestMismatchError, | ||
| NoDigestsError, | ||
| NoMetadataError, | ||
| NoProvenanceError, | ||
| NoSuchProjectError, | ||
| UnexpectedRepoVersionWarning, | ||
@@ -94,2 +97,3 @@ UnparsableFilenameError, | ||
| "NoMetadataError", | ||
| "NoProvenanceError", | ||
| "NoSuchProjectError", | ||
@@ -96,0 +100,0 @@ "PYPI_SIMPLE_ENDPOINT", |
@@ -13,3 +13,3 @@ from __future__ import annotations | ||
| from .pep691 import File, Project, ProjectList | ||
| from .util import basejoin, check_repo_version | ||
| from .util import basejoin, check_repo_version, url_add_suffix | ||
@@ -46,9 +46,9 @@ | ||
| #: | ||
| #: - ``'dumb'`` | ||
| #: - ``'egg'`` | ||
| #: - ``'msi'`` | ||
| #: - ``'rpm'`` | ||
| #: - ``'sdist'`` | ||
| #: - ``'wheel'`` | ||
| #: - ``'wininst'`` | ||
| #: - ``"dumb"`` | ||
| #: - ``"egg"`` | ||
| #: - ``"msi"`` | ||
| #: - ``"rpm"`` | ||
| #: - ``"sdist"`` | ||
| #: - ``"wheel"`` | ||
| #: - ``"wininst"`` | ||
| package_type: Optional[str] | ||
@@ -99,2 +99,12 @@ | ||
| #: .. 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 | ||
@@ -106,4 +116,3 @@ def sig_url(self) -> str: | ||
| """ | ||
| u = urlparse(self.url) | ||
| return urlunparse((u[0], u[1], u[2] + ".asc", "", "", "")) | ||
| return url_add_suffix(self.url, ".asc") | ||
@@ -116,5 +125,12 @@ @property | ||
| """ | ||
| 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 | ||
@@ -174,2 +190,3 @@ def from_link( | ||
| has_metadata=has_metadata, | ||
| provenance_sha256=link.get_str_attrib("data-provenance"), | ||
| ) | ||
@@ -228,2 +245,3 @@ | ||
| upload_time=file.upload_time, | ||
| provenance_sha256=file.provenance, | ||
| ) | ||
@@ -230,0 +248,0 @@ |
| from __future__ import annotations | ||
| from collections.abc import Callable, Iterator | ||
| import json | ||
| import os | ||
@@ -13,3 +14,8 @@ from pathlib import Path | ||
| 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 | ||
@@ -324,4 +330,4 @@ from .progress import ProgressTracker, null_progress_tracker | ||
| :param bool keep_on_error: | ||
| whether to keep (true) or delete (false) the downloaded file if an | ||
| error occurs | ||
| whether to keep (true) or delete (false; default) the downloaded | ||
| file if an error occurs | ||
| :param progress: a callable for constructing a progress tracker | ||
@@ -345,3 +351,3 @@ :param timeout: optional timeout to pass to the ``requests`` call | ||
| if verify: | ||
| digester = DigestChecker(pkg.digests) | ||
| digester = DigestChecker(pkg.digests, pkg.url) | ||
| else: | ||
@@ -419,3 +425,3 @@ digester = NullDigestChecker() | ||
| if verify: | ||
| digester = DigestChecker(pkg.metadata_digests or {}) | ||
| digester = DigestChecker(pkg.metadata_digests or {}, pkg.metadata_url) | ||
| else: | ||
@@ -425,3 +431,3 @@ digester = NullDigestChecker() | ||
| if r.status_code == 404: | ||
| raise NoMetadataError(pkg.filename) | ||
| raise NoMetadataError(pkg.filename, pkg.metadata_url) | ||
| r.raise_for_status() | ||
@@ -491,32 +497,57 @@ digester.update(r.content) | ||
| 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 | ||
| class NoSuchProjectError(Exception): | ||
| """ | ||
| Raised by `PyPISimple.get_project_page()` when a request for a project | ||
| fails with a 404 error code | ||
| """ | ||
| Retrieve the :pep:`740` ``.provenance`` file for the given | ||
| `DistributionPackage` and decode it as JSON. | ||
| 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 | ||
| 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. | ||
| def __str__(self) -> str: | ||
| return f"No details about project {self.project!r} available at {self.url}" | ||
| :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] | ||
| 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}" | ||
| :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, 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, pkg.provenance_url) | ||
| r.raise_for_status() | ||
| digester.update(r.content) | ||
| digester.finalize() | ||
| return json.loads(r.content) # type: ignore[no-any-return] |
@@ -55,19 +55,25 @@ class UnsupportedRepoVersionError(Exception): | ||
| """ | ||
| 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 | ||
| 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): | ||
| """ | ||
| 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__( | ||
| self, algorithm: str, expected_digest: str, actual_digest: str | ||
| self, *, algorithm: str, expected_digest: str, actual_digest: str, url: str | ||
| ) -> None: | ||
@@ -80,7 +86,11 @@ #: The name of the digest algorithm used | ||
| 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}" | ||
| ) | ||
@@ -102,1 +112,55 @@ | ||
| 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}" |
@@ -91,20 +91,20 @@ from __future__ import annotations | ||
| The package type may be any of the following strings: | ||
| The package type will be one of the following strings: | ||
| - ``'dumb'`` | ||
| - ``'egg'`` | ||
| - ``'msi'`` | ||
| - ``'rpm'`` | ||
| - ``'sdist'`` | ||
| - ``'wheel'`` | ||
| - ``'wininst'`` | ||
| - ``"dumb"`` | ||
| - ``"egg"`` | ||
| - ``"msi"`` | ||
| - ``"rpm"`` | ||
| - ``"sdist"`` | ||
| - ``"wheel"`` | ||
| - ``"wininst"`` | ||
| Note that some filenames (e.g., :file:`1-2-3.tar.gz`) may be ambiguous as | ||
| to which part is the project name and which is the version. In order to | ||
| resolve the ambiguity, the expected value for the project name (*modulo* | ||
| normalization) can be supplied as the ``project_name`` argument to the | ||
| function. If the filename can be parsed with the given string in the role | ||
| of the project name, the results of that parse will be returned; otherwise, | ||
| the function will fall back to breaking the project & version apart at an | ||
| unspecified point. | ||
| resolve the ambiguity, the expected value for the project name can be | ||
| supplied as the ``project_name`` argument to the function; it need not be | ||
| normalized. If the filename can be parsed with the given string in the | ||
| role of the project name, the results of that parse will be returned; | ||
| otherwise, the function will fall back to breaking the project & version | ||
| apart at an unspecified point. | ||
@@ -111,0 +111,0 @@ .. versionchanged:: 1.0.0 |
@@ -39,2 +39,3 @@ from __future__ import annotations | ||
| upload_time: Optional[datetime] = None | ||
| provenance: Optional[str] = None | ||
@@ -41,0 +42,0 @@ @property |
| from __future__ import annotations | ||
| from collections.abc import Callable | ||
| import sys | ||
| from types import TracebackType | ||
| from typing import TYPE_CHECKING, Any, Optional | ||
| from typing import TYPE_CHECKING, Any, Optional, Protocol, runtime_checkable | ||
| if sys.version_info[:2] >= (3, 8): | ||
| from typing import Protocol, runtime_checkable | ||
| else: | ||
| from typing_extensions import Protocol, runtime_checkable | ||
| if TYPE_CHECKING: | ||
@@ -26,4 +20,3 @@ from typing_extensions import Self | ||
| def __enter__(self) -> Self: | ||
| ... | ||
| def __enter__(self) -> Self: ... | ||
@@ -35,7 +28,5 @@ def __exit__( | ||
| exc_tb: Optional[TracebackType], | ||
| ) -> Optional[bool]: | ||
| ... | ||
| ) -> Optional[bool]: ... | ||
| def update(self, increment: int) -> None: | ||
| ... | ||
| def update(self, increment: int) -> None: ... | ||
@@ -42,0 +33,0 @@ |
@@ -5,3 +5,3 @@ from __future__ import annotations | ||
| from typing import Any, Optional | ||
| from urllib.parse import urljoin | ||
| from urllib.parse import urljoin, urlparse, urlunparse | ||
| import warnings | ||
@@ -50,8 +50,6 @@ from packaging.version import Version | ||
| @abstractmethod | ||
| def update(self, blob: bytes) -> None: | ||
| ... | ||
| def update(self, blob: bytes) -> None: ... | ||
| @abstractmethod | ||
| def finalize(self) -> None: | ||
| ... | ||
| def finalize(self) -> None: ... | ||
@@ -68,5 +66,6 @@ | ||
| 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(): | ||
@@ -81,3 +80,3 @@ try: | ||
| if not self.digesters: | ||
| raise NoDigestsError("No digests with known algorithms available") | ||
| raise NoDigestsError(self.url) | ||
@@ -96,2 +95,12 @@ def update(self, blob: bytes) -> None: | ||
| actual_digest=actual, | ||
| url=self.url, | ||
| ) | ||
| 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, "", "", "")) |
+148
-4
| from __future__ import annotations | ||
| import filecmp | ||
| import hashlib | ||
| import json | ||
@@ -16,2 +17,4 @@ from pathlib import Path | ||
| NoDigestsError, | ||
| NoMetadataError, | ||
| NoProvenanceError, | ||
| NoSuchProjectError, | ||
@@ -669,3 +672,6 @@ ProgressTracker, | ||
| 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() | ||
@@ -703,3 +709,3 @@ | ||
| 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'" | ||
@@ -741,3 +747,3 @@ " instead of expected" | ||
| 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'" | ||
@@ -880,3 +886,2 @@ " instead of expected" | ||
| assert simple.get_package_metadata(pkg) == "\u2603" | ||
| pkg = DistributionPackage( | ||
@@ -899,2 +904,34 @@ filename="example-0.0.2-py3-none-any.whl", | ||
| @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: | ||
@@ -941,1 +978,108 @@ with (DATA_DIR / "simple01.html").open() as fp: | ||
| simple.get_project_page("aws-adfs-ebsco", headers={"X-Custom": "foo"}) | ||
| @responses.activate | ||
| def test_get_provenance() -> None: | ||
| provenance = { | ||
| "version": 1, | ||
| "attestation_bundles": [ | ||
| { | ||
| "publisher": { | ||
| "kind": "important-ci-service", | ||
| "claims": {}, | ||
| "vendor-property": "foo", | ||
| "another-property": 123, | ||
| }, | ||
| "attestations": [ | ||
| { | ||
| "_type": "https://in-toto.io/Statement/v1", | ||
| "subject": [ | ||
| { | ||
| "name": "sampleproject-1.2.3.tar.gz", | ||
| "digest": { | ||
| "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" | ||
| }, | ||
| } | ||
| ], | ||
| "predicateType": "https://some-arbitrary-predicate.example.com/v1", | ||
| "predicate": {"something-else": "foo"}, | ||
| } | ||
| ], | ||
| } | ||
| ], | ||
| } | ||
| provenance_bytes = json.dumps(provenance, sort_keys=True, indent=4).encode("utf-8") | ||
| responses.add( | ||
| method=responses.GET, | ||
| url="https://test.nil/simple/packages/sampleproject-1.2.3-py3-none-any.whl.provenance", | ||
| body=provenance_bytes, | ||
| ) | ||
| 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, | ||
| provenance_sha256=hashlib.sha256(provenance_bytes).hexdigest(), | ||
| ) | ||
| assert simple.get_provenance(pkg, verify=True) == provenance | ||
| @responses.activate | ||
| def test_get_provenance_404() -> None: | ||
| responses.add( | ||
| method=responses.GET, | ||
| url="https://test.nil/simple/packages/sampleproject-1.2.3-py3-none-any.whl.provenance", | ||
| 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(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 at https://test.nil/simple/packages/sampleproject-1.2.3-py3-none-any.whl.provenance" | ||
| ) | ||
| @responses.activate | ||
| def test_get_provenance_verify_no_digest() -> None: | ||
| responses.add( | ||
| method=responses.GET, | ||
| url="https://test.nil/simple/packages/sampleproject-1.2.3-py3-none-any.whl.provenance", | ||
| 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, | ||
| provenance_sha256=None, | ||
| ) | ||
| with pytest.raises(NoDigestsError): | ||
| simple.get_provenance(pkg, verify=True) |
@@ -99,2 +99,3 @@ from __future__ import annotations | ||
| "data-yanked": "Oopsy.", | ||
| "data-provenance": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", | ||
| }, | ||
@@ -119,2 +120,3 @@ ), | ||
| has_metadata=True, | ||
| provenance_sha256="e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", | ||
| ), | ||
@@ -202,2 +204,23 @@ ), | ||
| def test_provenance_url() -> None: | ||
| pkg = DistributionPackage( | ||
| filename="qypi-0.1.0-py3-none-any.whl", | ||
| url="https://files.pythonhosted.org/packages/82/fc/9e25534641d7f63be93079bc07fa92bab136ddf5d4181059a1308a346f96/qypi-0.1.0-py3-none-any.whl", | ||
| digests={ | ||
| "sha256": "da69d28dcd527c0e372b3fa7b92fc333b327f8470175f035abc4e351b539189f" | ||
| }, | ||
| has_sig=True, | ||
| requires_python="~= 3.6", | ||
| project="qypi", | ||
| version="0.1.0", | ||
| package_type="wheel", | ||
| is_yanked=False, | ||
| yanked_reason=None, | ||
| ) | ||
| assert ( | ||
| pkg.provenance_url | ||
| == "https://files.pythonhosted.org/packages/82/fc/9e25534641d7f63be93079bc07fa92bab136ddf5d4181059a1308a346f96/qypi-0.1.0-py3-none-any.whl.provenance" | ||
| ) | ||
| def test_from_json_data_no_metadata() -> None: | ||
@@ -247,1 +270,20 @@ pkg = DistributionPackage.from_json_data( | ||
| assert pkg.metadata_digests == metadata_digests | ||
| def test_from_json_data_provenance() -> None: | ||
| pkg = DistributionPackage.from_json_data( | ||
| { | ||
| "filename": "argset-0.1.0-py3-none-any.whl", | ||
| "hashes": { | ||
| "sha256": "107a632c7112faceb9fd6e93658dd461154713db250f7ffde5bd473e17cf1db5" | ||
| }, | ||
| "requires-python": "~=3.6", | ||
| "url": "https://files.pythonhosted.org/packages/b5/2b/7aa284f345e37f955d86e4cd57b1039b573552b0fc29d1a522ec05c1ee41/argset-0.1.0-py3-none-any.whl", | ||
| "yanked": False, | ||
| "provenance": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", | ||
| } | ||
| ) | ||
| assert ( | ||
| pkg.provenance_sha256 | ||
| == "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" | ||
| ) |
+2
-2
| [tox] | ||
| envlist = lint,typing,py37,py38,py39,py310,py311,py312,pypy3 | ||
| envlist = lint,typing,py38,py39,py310,py311,py312,pypy3 | ||
| skip_missing_interpreters = True | ||
@@ -70,3 +70,3 @@ isolated_build = True | ||
| extend-select = B901,B902,B950 | ||
| ignore = A003,B005,E203,E262,E266,E501,U101,W503 | ||
| ignore = A003,A005,B005,E203,E262,E266,E501,E704,U101,W503 | ||
@@ -73,0 +73,0 @@ per-file-ignores = |
Alert delta unavailable
Currently unable to show alert delta for PyPI packages.
298148
4.67%6050
4.83%