Skip to content

Commit af42ac3

Browse files
committed
Added a download_package() method
1 parent 6821b75 commit af42ac3

File tree

11 files changed

+356
-16
lines changed

11 files changed

+356
-16
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ v0.10.0 (in development)
1818
parsing HTML responses
1919
- Warn on encountering a repository version with a greater minor version than
2020
expected
21+
- Gave `PyPISimple` a `download_package()` method
2122

2223
v0.9.0 (2021-08-26)
2324
-------------------

README.rst

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,9 @@ specified in :pep:`503` and updated by :pep:`592`, :pep:`629`, :pep:`658`, and
2828
:pep:`691`. With it, you can query `the Python Package Index (PyPI)
2929
<https://pypi.org>`_ and other `pip <https://pip.pypa.io>`_-compatible
3030
repositories for a list of their available projects and lists of each project's
31-
available package files. The library also allows you to query package files
32-
for their project version, package type, file digests, ``requires_python``
33-
string, PGP signature URL, and metadata URL.
31+
available package files. The library also allows you to download package files
32+
and query them for their project version, package type, file digests,
33+
``requires_python`` string, PGP signature URL, and metadata URL.
3434

3535
See `the documentation <https://pypi-simple.readthedocs.io>`_ for more
3636
information.

docs/changelog.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ v0.10.0 (in development)
2929

3030
- Warn on encountering a repository version with a greater minor version than
3131
expected
32+
- Gave `PyPISimple` a `~PyPISimple.download_package()` method
3233

3334

3435
v0.9.0 (2021-08-26)

docs/high-level-api.rst

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,20 @@ High-Level API
66
.. autoclass:: IndexPage()
77
.. autoclass:: ProjectPage()
88
.. autoclass:: DistributionPackage()
9+
10+
Constants
11+
---------
912
.. autodata:: PYPI_SIMPLE_ENDPOINT
1013
.. autodata:: SUPPORTED_REPOSITORY_VERSION
11-
.. autoexception:: UnsupportedContentTypeError
14+
15+
Exceptions
16+
----------
17+
.. autoexception:: DigestMismatchError()
18+
:show-inheritance:
19+
.. autoexception:: NoDigestsError()
20+
:show-inheritance:
21+
.. autoexception:: UnsupportedContentTypeError()
1222
:show-inheritance:
13-
.. autoexception:: UnsupportedRepoVersionError
14-
.. autoexception:: UnexpectedRepoVersionWarning
23+
.. autoexception:: UnsupportedRepoVersionError()
24+
.. autoexception:: UnexpectedRepoVersionWarning()
1525
:show-inheritance:

docs/index.rst

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,9 @@ specified in :pep:`503` and updated by :pep:`592`, :pep:`629`, :pep:`658`, and
2222
:pep:`691`. With it, you can query `the Python Package Index (PyPI)
2323
<https://pypi.org>`_ and other `pip <https://pip.pypa.io>`_-compatible
2424
repositories for a list of their available projects and lists of each project's
25-
available package files. The library also allows you to query package files
26-
for their project version, package type, file digests, ``requires_python``
27-
string, PGP signature URL, and metadata URL.
25+
available package files. The library also allows you to download package files
26+
and query them for their project version, package type, file digests,
27+
``requires_python`` string, PGP signature URL, and metadata URL.
2828

2929
Installation
3030
============

src/pypi_simple/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,15 +41,19 @@
4141
)
4242
from .parse_stream import parse_links_stream, parse_links_stream_response
4343
from .util import (
44+
DigestMismatchError,
45+
NoDigestsError,
4446
UnexpectedRepoVersionWarning,
4547
UnsupportedContentTypeError,
4648
UnsupportedRepoVersionError,
4749
)
4850

4951
__all__ = [
52+
"DigestMismatchError",
5053
"DistributionPackage",
5154
"IndexPage",
5255
"Link",
56+
"NoDigestsError",
5357
"PYPI_SIMPLE_ENDPOINT",
5458
"ProjectPage",
5559
"PyPISimple",

src/pypi_simple/client.py

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
1+
import os
2+
from pathlib import Path
13
import platform
2-
from typing import Any, Iterator, List, Optional, Tuple, Union
4+
from typing import Any, AnyStr, Iterator, List, Optional, Tuple, Union
35
from warnings import warn
46
from packaging.utils import canonicalize_name as normalize
57
import requests
68
from . import PYPI_SIMPLE_ENDPOINT, __url__, __version__
79
from .classes import DistributionPackage, IndexPage, ProjectPage
810
from .parse_repo import parse_repo_index_response, parse_repo_project_response
911
from .parse_stream import parse_links_stream_response
12+
from .util import AbstractDigestChecker, DigestChecker, NullDigestChecker
1013

1114
#: The User-Agent header used for requests; not used when the user provides eir
1215
#: own session object
@@ -206,6 +209,65 @@ def get_project_url(self, project: str) -> str:
206209
"""
207210
return self.endpoint + normalize(project) + "/"
208211

212+
def download_package(
213+
self,
214+
pkg: DistributionPackage,
215+
path: Union[AnyStr, "os.PathLike[AnyStr]"],
216+
verify: bool = True,
217+
keep_on_error: bool = False,
218+
timeout: Union[float, Tuple[float, float], None] = None,
219+
) -> None:
220+
"""
221+
.. versionadded:: 0.10.0
222+
223+
Download the given `DistributionPackage` to the given path.
224+
225+
If an error occurs while downloading or verifying digests, and
226+
``keep_on_error`` is not true, the downloaded file is not saved.
227+
228+
:param DistributionPackage pkg: the distribution package to download
229+
:param path:
230+
the path at which to save the downloaded file; any parent
231+
directories of this path will be created as needed
232+
:param bool verify:
233+
whether to verify the package's digests against the downloaded file
234+
:param bool keep_on_error:
235+
whether to keep (true) or delete (false) the downloaded file if an
236+
error occurs
237+
:param timeout: optional timeout to pass to the ``requests`` call
238+
:type timeout: Union[float, Tuple[float,float], None]
239+
:raises requests.HTTPError: if the repository responds with an HTTP
240+
error code
241+
:raises NoDigestsError:
242+
if ``verify`` is true and the given package does not have any
243+
digests with known algorithms
244+
:raises DigestMismatchError:
245+
if ``verify`` is true and the digest of the downloaded file does
246+
not match the expected value
247+
"""
248+
target = Path(os.fsdecode(path))
249+
target.parent.mkdir(parents=True, exist_ok=True)
250+
digester: AbstractDigestChecker
251+
if verify:
252+
digester = DigestChecker(pkg.digests)
253+
else:
254+
digester = NullDigestChecker()
255+
with self.s.get(pkg.url, stream=True, timeout=timeout) as r:
256+
r.raise_for_status()
257+
try:
258+
with target.open("wb") as fp:
259+
for chunk in r.iter_content(65535):
260+
fp.write(chunk)
261+
digester.update(chunk)
262+
digester.finalize()
263+
except Exception:
264+
if not keep_on_error:
265+
try:
266+
target.unlink()
267+
except FileNotFoundError:
268+
pass
269+
raise
270+
209271
def get_projects(self) -> Iterator[str]:
210272
"""
211273
Returns a generator of names of projects available in the repository.

src/pypi_simple/util.py

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
from typing import Optional
1+
from abc import ABC, abstractmethod
2+
import hashlib
3+
from typing import Any, Dict, Optional
24
from urllib.parse import urljoin
35
import warnings
46
from packaging.version import Version
@@ -86,3 +88,87 @@ def basejoin(base_url: Optional[str], url: str) -> str:
8688
return url
8789
else:
8890
return urljoin(base_url, url)
91+
92+
93+
class AbstractDigestChecker(ABC):
94+
@abstractmethod
95+
def update(self, blob: bytes) -> None:
96+
...
97+
98+
@abstractmethod
99+
def finalize(self) -> None:
100+
pass
101+
102+
103+
class NullDigestChecker(AbstractDigestChecker):
104+
def update(self, blob: bytes) -> None:
105+
pass
106+
107+
def finalize(self) -> None:
108+
pass
109+
110+
111+
class DigestChecker(AbstractDigestChecker):
112+
def __init__(self, digests: Dict[str, str]) -> None:
113+
self.digesters: Dict[str, Any] = {}
114+
self.expected: Dict[str, str] = {}
115+
for alg, value in digests.items():
116+
try:
117+
d = hashlib.new(alg)
118+
except ValueError:
119+
pass
120+
else:
121+
self.digesters[alg] = d
122+
self.expected[alg] = value
123+
if not self.digesters:
124+
raise NoDigestsError("No digests with known algorithms available")
125+
126+
def update(self, blob: bytes) -> None:
127+
for d in self.digesters.values():
128+
d.update(blob)
129+
130+
def finalize(self) -> None:
131+
for alg, d in self.digesters.items():
132+
actual = d.hexdigest()
133+
if actual != self.expected[alg]:
134+
raise DigestMismatchError(
135+
algorithm=alg,
136+
expected_digest=self.expected[alg],
137+
actual_digest=actual,
138+
)
139+
140+
141+
class NoDigestsError(ValueError):
142+
"""
143+
.. versionadded:: 0.10.0
144+
145+
Raised by `PyPISimple.download_package()` with ``verify=True`` when the
146+
given package does not have any digests with known algorithms
147+
"""
148+
149+
pass
150+
151+
152+
class DigestMismatchError(ValueError):
153+
"""
154+
.. versionadded:: 0.10.0
155+
156+
Raised by `PyPISimple.download_package()` with ``verify=True`` when the
157+
digest of the downloaded file does not match the expected value
158+
"""
159+
160+
def __init__(
161+
self, algorithm: str, expected_digest: str, actual_digest: str
162+
) -> None:
163+
#: The name of the digest algorithm used
164+
self.algorithm = algorithm
165+
#: The expected digest
166+
self.expected_digest = expected_digest
167+
#: The digest of the file that was actually received
168+
self.actual_digest = actual_digest
169+
170+
def __str__(self) -> str:
171+
return (
172+
f"{self.algorithm} digest of downloaded file is"
173+
f" {self.actual_digest!r} instead of expected {self.expected_digest!r}"
174+
)
5.2 KB
Binary file not shown.

0 commit comments

Comments
 (0)