Skip to content

Commit e1862f4

Browse files
committed
Support using progress bars when downloading
1 parent af42ac3 commit e1862f4

File tree

11 files changed

+254
-10
lines changed

11 files changed

+254
-10
lines changed

README.rst

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,19 @@ Installation
4444

4545
python3 -m pip install pypi-simple
4646

47+
``pypi-simple`` can optionally make use of tqdm_. To install it alongside
48+
``pypi-simple``, specify the ``tqdm`` extra::
49+
50+
python3 -m pip install "pypi-simple[tqdm]"
51+
52+
.. _tqdm: https://tqdm.github.io
53+
4754

4855
Example
4956
=======
5057

58+
Get information about a package:
59+
5160
>>> from pypi_simple import PyPISimple
5261
>>> with PyPISimple() as client:
5362
... requests_page = client.get_project_page('requests')
@@ -64,3 +73,16 @@ Example
6473
'sdist'
6574
>>> pkg.digests
6675
{'sha256': '813202ace4d9301a3c00740c700e012fb9f3f8c73ddcfe02ab558a8df6f175fd'}
76+
77+
Download a package with a tqdm progress bar:
78+
79+
.. code:: python
80+
81+
from pypi_simple import PyPISimple, tqdm_progress_factory
82+
83+
with PyPISimple() as client:
84+
page = client.get_project_page("pypi-simple")
85+
pkg = page.packages[-1]
86+
client.download_package(
87+
pkg, path=pkg.filename, progress=tqdm_progress_factory(),
88+
)

docs/conf.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919

2020
intersphinx_mapping = {
2121
"python": ("https://docs.python.org/3", None),
22-
"requests": ("https://requests.readthedocs.io/en/master/", None),
22+
"requests": ("https://requests.readthedocs.io/en/latest/", None),
2323
}
2424

2525
exclude_patterns = ["_build"]

docs/high-level-api.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@ Constants
1212
.. autodata:: PYPI_SIMPLE_ENDPOINT
1313
.. autodata:: SUPPORTED_REPOSITORY_VERSION
1414

15+
Progress Trackers
16+
-----------------
17+
.. autoclass:: ProgressTracker()
18+
:special-members: __enter__, __exit__
19+
.. autofunction:: tqdm_progress_factory
20+
1521
Exceptions
1622
----------
1723
.. autoexception:: DigestMismatchError()

docs/index.rst

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,19 @@ Installation
3434

3535
python3 -m pip install pypi-simple
3636

37+
``pypi-simple`` can optionally make use of tqdm_. To install it alongside
38+
``pypi-simple``, specify the ``tqdm`` extra::
39+
40+
python3 -m pip install "pypi-simple[tqdm]"
41+
42+
.. _tqdm: https://tqdm.github.io
43+
3744

3845
Example
3946
=======
4047

48+
Get information about a package:
49+
4150
>>> from pypi_simple import PyPISimple
4251
>>> with PyPISimple() as client:
4352
... requests_page = client.get_project_page('requests')
@@ -55,6 +64,19 @@ Example
5564
>>> pkg.digests
5665
{'sha256': '813202ace4d9301a3c00740c700e012fb9f3f8c73ddcfe02ab558a8df6f175fd'}
5766

67+
Download a package with a tqdm progress bar:
68+
69+
.. code:: python
70+
71+
from pypi_simple import PyPISimple, tqdm_progress_factory
72+
73+
with PyPISimple() as client:
74+
page = client.get_project_page("pypi-simple")
75+
pkg = page.packages[-1]
76+
client.download_package(
77+
pkg, path=pkg.filename, progress=tqdm_progress_factory(),
78+
)
79+
5880
5981
Indices and tables
6082
==================

setup.cfg

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,11 @@ install_requires =
5151
mailbits ~= 0.2
5252
packaging >= 20
5353
requests ~= 2.20
54+
typing_extensions; python_version < "3.8"
55+
56+
[options.extras_require]
57+
tqdm =
58+
tqdm
5459

5560
[options.packages.find]
5661
where = src

src/pypi_simple/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
parse_repo_project_response,
4141
)
4242
from .parse_stream import parse_links_stream, parse_links_stream_response
43+
from .progress import ProgressTracker, tqdm_progress_factory
4344
from .util import (
4445
DigestMismatchError,
4546
NoDigestsError,
@@ -55,6 +56,7 @@
5556
"Link",
5657
"NoDigestsError",
5758
"PYPI_SIMPLE_ENDPOINT",
59+
"ProgressTracker",
5860
"ProjectPage",
5961
"PyPISimple",
6062
"SUPPORTED_REPOSITORY_VERSION",
@@ -74,4 +76,5 @@
7476
"parse_repo_project_page",
7577
"parse_repo_project_response",
7678
"parse_simple_index",
79+
"tqdm_progress_factory",
7780
]

src/pypi_simple/client.py

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
import os
22
from pathlib import Path
33
import platform
4-
from typing import Any, AnyStr, Iterator, List, Optional, Tuple, Union
4+
from types import TracebackType
5+
from typing import Any, AnyStr, Callable, Iterator, List, Optional, Tuple, Type, Union
56
from warnings import warn
67
from packaging.utils import canonicalize_name as normalize
78
import requests
89
from . import PYPI_SIMPLE_ENDPOINT, __url__, __version__
910
from .classes import DistributionPackage, IndexPage, ProjectPage
1011
from .parse_repo import parse_repo_index_response, parse_repo_project_response
1112
from .parse_stream import parse_links_stream_response
13+
from .progress import ProgressTracker, null_progress_tracker
1214
from .util import AbstractDigestChecker, DigestChecker, NullDigestChecker
1315

1416
#: The User-Agent header used for requests; not used when the user provides eir
@@ -96,7 +98,12 @@ def __init__(
9698
def __enter__(self) -> "PyPISimple":
9799
return self
98100

99-
def __exit__(self, _exc_type: Any, _exc_value: Any, _exc_tb: Any) -> None:
101+
def __exit__(
102+
self,
103+
_exc_type: Optional[Type[BaseException]],
104+
_exc_val: Optional[BaseException],
105+
_exc_tb: Optional[TracebackType],
106+
) -> None:
100107
self.s.close()
101108

102109
def get_index_page(
@@ -215,6 +222,7 @@ def download_package(
215222
path: Union[AnyStr, "os.PathLike[AnyStr]"],
216223
verify: bool = True,
217224
keep_on_error: bool = False,
225+
progress: Optional[Callable[[Optional[int]], ProgressTracker]] = None,
218226
timeout: Union[float, Tuple[float, float], None] = None,
219227
) -> None:
220228
"""
@@ -225,6 +233,13 @@ def download_package(
225233
If an error occurs while downloading or verifying digests, and
226234
``keep_on_error`` is not true, the downloaded file is not saved.
227235
236+
Download progress can be tracked (e.g., for display by a progress bar)
237+
by passing an appropriate callable as the ``progress`` argument. This
238+
callable will be passed the length of the downloaded file, if known,
239+
and it must return a `ProgressTracker` — a context manager with an
240+
``update(increment: int)`` method that will be passed the size of each
241+
downloaded chunk as each chunk is received.
242+
228243
:param DistributionPackage pkg: the distribution package to download
229244
:param path:
230245
the path at which to save the downloaded file; any parent
@@ -234,6 +249,7 @@ def download_package(
234249
:param bool keep_on_error:
235250
whether to keep (true) or delete (false) the downloaded file if an
236251
error occurs
252+
:param progress: a callable for contructing a progress tracker
237253
:param timeout: optional timeout to pass to the ``requests`` call
238254
:type timeout: Union[float, Tuple[float,float], None]
239255
:raises requests.HTTPError: if the repository responds with an HTTP
@@ -255,10 +271,18 @@ def download_package(
255271
with self.s.get(pkg.url, stream=True, timeout=timeout) as r:
256272
r.raise_for_status()
257273
try:
258-
with target.open("wb") as fp:
259-
for chunk in r.iter_content(65535):
260-
fp.write(chunk)
261-
digester.update(chunk)
274+
content_length = int(r.headers["Content-Length"])
275+
except (ValueError, KeyError):
276+
content_length = None
277+
if progress is None:
278+
progress = null_progress_tracker()
279+
try:
280+
with progress(content_length) as p:
281+
with target.open("wb") as fp:
282+
for chunk in r.iter_content(65535):
283+
fp.write(chunk)
284+
digester.update(chunk)
285+
p.update(len(chunk))
262286
digester.finalize()
263287
except Exception:
264288
if not keep_on_error:

src/pypi_simple/progress.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import sys
2+
from types import TracebackType
3+
from typing import Any, Callable, Optional, Type, TypeVar, cast
4+
5+
if sys.version_info[:2] >= (3, 8):
6+
from typing import Protocol, runtime_checkable
7+
else:
8+
from typing_extensions import Protocol, runtime_checkable
9+
10+
11+
T = TypeVar("T", bound="ProgressTracker")
12+
13+
14+
@runtime_checkable
15+
class ProgressTracker(Protocol):
16+
"""
17+
A `typing.Protocol` for progress trackers. A progress tracker must be
18+
usable as a context manager whose ``__enter__`` method performs startup &
19+
returns itself and whose ``__exit__`` method performs shutdown/cleanup. In
20+
addition, a progress tracker must have an ``update(increment: int)`` method
21+
that will be called with the size of each downloaded file chunk.
22+
"""
23+
24+
def __enter__(self: T) -> T:
25+
...
26+
27+
def __exit__(
28+
self,
29+
exc_type: Optional[Type[BaseException]],
30+
exc_val: Optional[BaseException],
31+
exc_tb: Optional[TracebackType],
32+
) -> Optional[bool]:
33+
...
34+
35+
def update(self, increment: int) -> None:
36+
...
37+
38+
39+
class NullProgressTracker:
40+
def __enter__(self) -> "NullProgressTracker":
41+
return self
42+
43+
def __exit__(
44+
self,
45+
exc_type: Optional[Type[BaseException]],
46+
exc_val: Optional[BaseException],
47+
exc_tb: Optional[TracebackType],
48+
) -> None:
49+
pass
50+
51+
def update(self, increment: int) -> None:
52+
pass
53+
54+
55+
def null_progress_tracker() -> Callable[[Optional[int]], ProgressTracker]:
56+
def factory(_content_length: Optional[int]) -> ProgressTracker:
57+
return NullProgressTracker()
58+
59+
return factory
60+
61+
62+
def tqdm_progress_factory(**kwargs: Any) -> Callable[[Optional[int]], ProgressTracker]:
63+
"""
64+
A function for displaying a progress bar with tqdm_ during a download.
65+
Naturally, using this requires tqdm to be installed alongside
66+
``pypi-simple``.
67+
68+
Call `tqdm_progress_factory()` with any arguments you wish to pass to the
69+
``tqdm.tqdm`` constructor, and pass the result as the ``progress`` argument
70+
to `PyPISimple.download_package()`.
71+
72+
.. _tqdm: https://tqdm.github.io
73+
74+
Example:
75+
76+
.. code:: python
77+
78+
with PyPISimple() as client:
79+
page = client.get_project_page("pypi-simple")
80+
pkg = page.packages[-1]
81+
client.download_package(
82+
pkg,
83+
path=pkg.filename,
84+
progress=tqdm_progress_factory(desc="Downloading ..."),
85+
)
86+
"""
87+
88+
from tqdm import tqdm
89+
90+
def factory(content_length: Optional[int]) -> ProgressTracker:
91+
return cast(ProgressTracker, tqdm(total=content_length, **kwargs))
92+
93+
return factory

src/pypi_simple/util.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ def update(self, blob: bytes) -> None:
9797

9898
@abstractmethod
9999
def finalize(self) -> None:
100-
pass
100+
...
101101

102102

103103
class NullDigestChecker(AbstractDigestChecker):

0 commit comments

Comments
 (0)