Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,5 @@ doc/api/generated
MANIFEST
.coverage.*
pooch/_version.py
/.venv/
/scratch/
1 change: 1 addition & 0 deletions doc/api/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ Downloaders
pooch.FTPDownloader
pooch.SFTPDownloader
pooch.DOIDownloader
pooch.FileDownloader

Processors
----------
Expand Down
2 changes: 1 addition & 1 deletion doc/downloaders.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Downloaders: Customizing the download
=====================================

By default, :meth:`pooch.Pooch.fetch` and :meth:`pooch.retrieve` will detect
the download protocol from the given URL (HTTP, FTP, SFTP, DOI) and use the
the download protocol from the given URL (HTTP, FTP, SFTP, FILE, DOI) and use the
appropriate download method.
Sometimes this is not enough: some servers require logins, redirections, or
other non-standard operations.
Expand Down
24 changes: 24 additions & 0 deletions doc/progressbars.rst
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,30 @@ Alternatively, you can pass ``progressbar=True`` directly into one of our
``tqdm`` is not installed by default with Pooch. You will have to install
it separately in order to use this feature.

.. _tqdm-file-progressbar:

Using file progress bars
------------------------

The ``tqdm`` progress bar can also be used to show the progress of
file copy with the file protocol ``file://``.

This is done by passing ``progressbar=True`` to the :func:`pooch.retrieve`
function or the :meth:`pooch.Pooch.fetch` method:

.. code:: python

# Using retrieve
fname = retrieve(
url="file://path/to/local/file.txt",
known_hash="md5:70e2afd3fd7e336ae478b1e740a5f08e",
progressbar=True,
)

.. note::

The file progress bar can impact performance when copying large files. The impact
can be mitigated by increasing the ``chunk_size`` argument.

.. _custom-progressbar:

Expand Down
2 changes: 1 addition & 1 deletion doc/protocols.rst
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
Download protocols
==================

Pooch supports the HTTP, FTP, and SFTP protocols by default.
Pooch supports the HTTP, FTP, SFTP and FILE protocols by default.
It also includes a custom protocol for Digital Object Identifiers (DOI) from
providers like `figshare <https://www.figshare.com>`__ and `Zenodo
<https://www.zenodo.org>`__ (see :ref:`below <doidownloads>`).
Expand Down
1 change: 1 addition & 0 deletions pooch/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
FTPDownloader,
SFTPDownloader,
DOIDownloader,
FileDownloader,
)
from .processors import Unzip, Untar, Decompress

Expand Down
107 changes: 106 additions & 1 deletion pooch/downloaders.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,13 @@
The classes that actually handle the downloads.
"""
import os
import shutil
import sys
import ftplib

import warnings
from contextlib import nullcontext
from pathlib import Path

from .utils import parse_url

Expand All @@ -27,7 +30,6 @@
except ImportError:
paramiko = None # type: ignore


# Set the default timeout in seconds so it can be configured in a pinch for the
# methods that don't or can't expose a way set it at runtime.
# See https://github.com/fatiando/pooch/issues/409
Expand Down Expand Up @@ -77,6 +79,7 @@ def choose_downloader(url, progressbar=False):
"http": HTTPDownloader,
"sftp": SFTPDownloader,
"doi": DOIDownloader,
"file": FileDownloader,
}

parsed_url = parse_url(url)
Expand Down Expand Up @@ -505,6 +508,108 @@ def callback(current, total):
sftp.close()


class FileDownloader: # pylint: disable=too-few-public-methods
"""
Download manager for fetching files over a file system mounted in the OS.

When called, downloads the given file path into the specified local file.
Uses :mod:`shutil` to copy files from paths.

.. note::

Progressbar does add overhead. To mitigate this, consider increasing
the chunk_size for large files.
"""

def __init__(self, progressbar=False, chunk_size=1024, **kwargs):
self.kwargs = kwargs
self.progressbar = progressbar
self.chunk_size = chunk_size

if self.progressbar and tqdm is None:
raise ImportError("Missing package 'tqdm' required for progress bars.")

def __call__(
self, url, output_file, pooch, check_only=False
): # pylint: disable=R0914
"""
Download the given file in the filesystem to the given output file.

Uses :func:`shutil.copyfile` or :func:`shutil.copyfileobj` and
:func:`shutil.copystat`.

Parameters
----------
url : str
The url path to the file you want to download.
output_file : str or file-like object
Path (and file name) to which the file will be downloaded.
pooch : :class:`~pooch.Pooch`
The instance of :class:`~pooch.Pooch` that is calling this method.
check_only : bool
If True, will only check if a file exists in the directory and
**without downloading the file**. Will return ``True`` if the file
exists and ``False`` otherwise.

Returns
-------
availability : bool or None
If ``check_only==True``, returns a boolean indicating if the file
is available on the server. Otherwise, returns ``None``.

"""

parsed_url = parse_url(url)
source_path = Path(parsed_url["netloc"] + parsed_url["path"])

if check_only:
return source_path.exists()

ispath = not hasattr(output_file, "write")
total_size = source_path.stat().st_size # Get the total file size

with source_path.open("rb") as fsrc:
if self.progressbar:
# Wrap the source file in a progress bar
with tqdm(
total=total_size, unit="B", unit_scale=True, leave=True
) as progress:
fsrc = ProgressFileWrapper(fsrc, progress)
if ispath:
with open(output_file, "wb") as fdst:
shutil.copyfileobj(fsrc, fdst, length=self.chunk_size)
shutil.copystat(source_path, output_file)
else:
shutil.copyfileobj(fsrc, output_file, length=self.chunk_size)
else:
# Use shutil directly for simplicity
if ispath:
shutil.copyfile(source_path, output_file)
shutil.copystat(source_path, output_file)
else:
shutil.copyfileobj(fsrc, output_file)

return None


class ProgressFileWrapper:
"""
A file-like wrapper that updates a progress bar as data is read.
"""

def __init__(self, file, progress):
self.file = file
self.progress = progress

def read(self, size=-1):
chunk = self.file.read(size)
self.progress.update(len(chunk))
return chunk

def __getattr__(self, attr):
return getattr(self.file, attr)


class DOIDownloader: # pylint: disable=too-few-public-methods
"""
Download manager for fetching files from Digital Object Identifiers (DOIs).
Expand Down
91 changes: 91 additions & 0 deletions pooch/tests/test_downloaders.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
"""
import os
import sys
from pathlib import Path
from shutil import SameFileError
from tempfile import TemporaryDirectory

import pytest
Expand All @@ -31,6 +33,7 @@
HTTPDownloader,
FTPDownloader,
SFTPDownloader,
FileDownloader,
DOIDownloader,
choose_downloader,
FigshareRepository,
Expand Down Expand Up @@ -262,6 +265,94 @@ def test_sftp_downloader_fail_if_paramiko_missing():
assert "'paramiko'" in str(exc.value)


def test_file_downloader(tmp_path):
"Test file downloader"
src = tmp_path / "tiny-data.txt"
with open(src, "w") as f:
f.write("This is a test file.")
url = Path(src).as_uri()

with TemporaryDirectory() as local_store:
downloader = FileDownloader()
outfile = os.path.join(local_store, "tiny-data.txt")
downloader(url, outfile, None)
assert os.path.exists(outfile)
# Check that the file was actually downloaded and content as expected
with open(outfile, "r") as f:
content = f.read()
assert content == "This is a test file."


def test_file_downloader_progress(tmp_path):
"Test file downloader with progress bar"
src = tmp_path / "tiny-data.txt"
with open(src, "w") as f:
f.write("This is a test file.")
url = Path(src).as_uri()

with TemporaryDirectory() as local_store:
downloader = FileDownloader(progressbar=True)
outfile = os.path.join(local_store, "tiny-data.txt")
downloader(url, outfile, None)
assert os.path.exists(outfile)
# Check that the file was actually downloaded and content as expected
with open(outfile, "r") as f:
content = f.read()
assert content == "This is a test file."


def test_file_downloader_chunked_copy(tmp_path):
"""Test FileDownloader with chunked file copying and progress bar."""
# Create a source file with some content
src = tmp_path / "large-data.txt"
content = b"A" * (1024 * 1024 * 5) # 5 MB file
with open(src, "wb") as f:
f.write(content)
url = Path(src).as_uri()

with TemporaryDirectory() as local_store:
print(local_store)
# Enable progress bar and set a chunk size
downloader = FileDownloader(
progressbar=False, chunk_size=1024 * 1024
) # 1 MB chunks
outfile = os.path.join(local_store, "copied-large-data.txt")
with open(outfile, "wb") as f:
downloader(url, f, None)

# Verify the file exists and content matches
assert os.path.exists(outfile)
with open(outfile, "rb") as f:
copied_content = f.read()
assert copied_content == content


def test_file_downloader_chunked_copy_with_progress(tmp_path):
"""Test FileDownloader with chunked file copying and progress bar."""
# Create a source file with some content
src = tmp_path / "large-data.txt"
content = b"A" * (1024 * 1024 * 5) # 5 MB file
with open(src, "wb") as f:
f.write(content)
url = Path(src).as_uri()

with TemporaryDirectory() as local_store:
print(local_store)
# Enable progress bar and set a chunk size
downloader = FileDownloader(
progressbar=True, chunk_size=1024 * 1024
) # 1 MB chunks
outfile = os.path.join(local_store, "copied-large-data.txt")
with open(outfile, "wb") as f:
downloader(url, f, None)

# Verify the file exists and content matches
assert os.path.exists(outfile)
with open(outfile, "rb") as f:
copied_content = f.read()
assert copied_content == content


@pytest.mark.skipif(tqdm is not None, reason="tqdm must be missing")
@pytest.mark.parametrize("downloader", [HTTPDownloader, FTPDownloader, SFTPDownloader])
def test_downloader_progressbar_fails(downloader):
Expand Down
5 changes: 5 additions & 0 deletions pooch/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,11 @@ def parse_url(url: str) -> ParsedURL:
protocol = parsed_url.scheme or "file"
netloc = parsed_url.netloc
path = parsed_url.path

# Handle Windows-specific behavior for file:// URLs
if protocol == "file" and os.name == "nt" and path.startswith("/"):
path = path.lstrip("/")

return {"protocol": protocol, "netloc": netloc, "path": path}


Expand Down
Loading