Skip to content

cdm_data_loaders.utils.file_transfer.async_client

Asynchronous file download client.

Usage:

initialise a download client that downloads files in chunks of 1024 bytes and retries the downlooad

three times in case of connection/timeout errors

async_client = AsyncFileDownloader(max_attempts=3, chunk_size=1024)

output_path will be the path to the saved file

output_path = await async_client.download( "https://example.com/file.txt", Path("/path") / "to" / "save" / "file.txt"), expected_checksum="some_checksum", checksum_fn="sha512", extra_headers={ "If-None-Match": "abc", "If-Modified-Since": "Wed, 21 Oct 2015 07:28:00 GMT" })

AsyncFileDownloader

Bases: BaseFileDownloader

Asynchronous downloader interface.

Source code in src/cdm_data_loaders/utils/file_transfer/async_client.py
class AsyncFileDownloader(BaseFileDownloader):
    """Asynchronous downloader interface."""

    def __init__(  # noqa: PLR0913
        self,
        client: httpx.AsyncClient | None = None,
        max_attempts: int = RETRY_DEFAULTS["max_attempts"],
        min_backoff: int = RETRY_DEFAULTS["min_backoff"],
        max_backoff: int = RETRY_DEFAULTS["max_backoff"],
        chunk_size: int = 8192,
        max_concurrency: int | None = None,
    ) -> None:
        """Initialise an async download client.

        :param client: an httpx.Client object, defaults to None
        :type client: httpx.Client | None, optional
        :param max_attempts: how many times to retry the request defaults to 5
        :type max_attempts: int, optional
        :param min_backoff: minimum backoff for retries, defaults to 1
        :type min_backoff: int, optional
        :param max_backoff: maximum backoff for retries, defaults to 30
        :type max_backoff: int, optional
        :param chunk_size: chunk size for the downloader, defaults to 8192
        :type chunk_size: int, optional
        :param max_concurrency: maximum concurrency for the downloader, defaults to None
        :type max_concurrency: int, optional
        """
        super().__init__(client or get_async_httpx_client(), chunk_size=chunk_size)
        self.semaphore = asyncio.Semaphore(max_concurrency) if max_concurrency else None

        self._retry = AsyncRetrying(**DownloadCore.retry_kwargs(max_attempts, min_backoff, max_backoff))

    async def download(
        self,
        url: str,
        destination: str | Path,
        expected_checksum: str | None = None,
        checksum_fn: str | None = "sha256",
        extra_headers: dict[str, Any] | None = None,
    ) -> Path | None:
        """Download a file from ``url`` to ``destination`` on disk.

        Returns the file path if successful; None otherwise.
        304 / Not Modified responses return None.

        :param url: URL to download from
        :type url: str
        :param destination: where to save the file to
        :type destination: str | Path
        :param expected_checksum: expected checksum (if known), defaults to None
        :type expected_checksum: str | None, optional
        :param checksum_fn: function used for calculating the checksum, defaults to "sha256"
        :type checksum_fn: str, optional
        :param extra_headers: allow extra headers to be passed, defaults to None
        :type extra_headers: dict[str, Any] | None, optional
        :return: either the path to the downloaded file or None if no file was downloaded
        :rtype: Path | None
        """
        destination, checksum_fn, extra_headers = DownloadCore.validate_args(destination, checksum_fn, extra_headers)

        async with self.semaphore or contextlib.nullcontext():
            return await self._download_with_retry(
                url,
                destination,
                expected_checksum,
                checksum_fn,
                extra_headers,
            )

    async def _download_with_retry(self, *args) -> Path | None:
        async for attempt in self._retry:
            with attempt:
                return await self._download_once(*args)
        # this should never be raised under normal circumstances
        # tenacity should never exhaust silently
        msg = "Iterator exhausted, unreachable code reached!"
        raise RuntimeError(msg)

    async def _download_once(
        self,
        url: str,
        destination: Path,
        expected_checksum: str | None,
        checksum_fn: str,
        extra_headers: dict[str, Any],
    ) -> Path | None:
        """Core download function.

        :param url: URL to download from
        :type url: str
        :param destination: where to save the file to
        :type destination: Path
        :param expected_checksum: expected checksum (if known), defaults to None
        :type expected_checksum: str | None, optional
        :param checksum_fn: function used for calculating the checksum
        :type checksum_fn: str
        :param extra_headers: any extra headers to pass to the request
        :type extra_headers: dict[str, Any]
        :raises DownloadError: for unrecoverable download errors
        :raises ChecksumMismatchError: if the checksum does not match expected_checksum
        :return: either the path to the downloaded file or None if no file was downloaded
        :rtype: Path | None
        """
        try:
            async with self.client.stream("GET", url, headers=extra_headers) as response:
                if not DownloadCore.check_should_download(response, url, logger):
                    return None

                chunks = response.aiter_bytes(chunk_size=self.chunk_size)

                await DownloadCore.write_and_hash_async(
                    url,
                    destination,
                    chunks,
                    expected_checksum=expected_checksum,
                    checksum_fn=checksum_fn,
                )

        except Exception as exc:  # noqa: BLE001
            DownloadCore.handle_download_exception(exc, url, logger)

        return DownloadCore.log_download_success(url, destination, logger)

__init__

__init__(
    client: AsyncClient | None = None,
    max_attempts: int = RETRY_DEFAULTS["max_attempts"],
    min_backoff: int = RETRY_DEFAULTS["min_backoff"],
    max_backoff: int = RETRY_DEFAULTS["max_backoff"],
    chunk_size: int = 8192,
    max_concurrency: int | None = None,
) -> None

Initialise an async download client.

Parameters:

Name Type Description Default
client AsyncClient | None

an httpx.Client object, defaults to None

None
max_attempts int

how many times to retry the request defaults to 5

RETRY_DEFAULTS['max_attempts']
min_backoff int

minimum backoff for retries, defaults to 1

RETRY_DEFAULTS['min_backoff']
max_backoff int

maximum backoff for retries, defaults to 30

RETRY_DEFAULTS['max_backoff']
chunk_size int

chunk size for the downloader, defaults to 8192

8192
max_concurrency int | None

maximum concurrency for the downloader, defaults to None

None
Source code in src/cdm_data_loaders/utils/file_transfer/async_client.py
def __init__(  # noqa: PLR0913
    self,
    client: httpx.AsyncClient | None = None,
    max_attempts: int = RETRY_DEFAULTS["max_attempts"],
    min_backoff: int = RETRY_DEFAULTS["min_backoff"],
    max_backoff: int = RETRY_DEFAULTS["max_backoff"],
    chunk_size: int = 8192,
    max_concurrency: int | None = None,
) -> None:
    """Initialise an async download client.

    :param client: an httpx.Client object, defaults to None
    :type client: httpx.Client | None, optional
    :param max_attempts: how many times to retry the request defaults to 5
    :type max_attempts: int, optional
    :param min_backoff: minimum backoff for retries, defaults to 1
    :type min_backoff: int, optional
    :param max_backoff: maximum backoff for retries, defaults to 30
    :type max_backoff: int, optional
    :param chunk_size: chunk size for the downloader, defaults to 8192
    :type chunk_size: int, optional
    :param max_concurrency: maximum concurrency for the downloader, defaults to None
    :type max_concurrency: int, optional
    """
    super().__init__(client or get_async_httpx_client(), chunk_size=chunk_size)
    self.semaphore = asyncio.Semaphore(max_concurrency) if max_concurrency else None

    self._retry = AsyncRetrying(**DownloadCore.retry_kwargs(max_attempts, min_backoff, max_backoff))

download async

download(
    url: str,
    destination: str | Path,
    expected_checksum: str | None = None,
    checksum_fn: str | None = "sha256",
    extra_headers: dict[str, Any] | None = None,
) -> Path | None

Download a file from url to destination on disk.

Returns the file path if successful; None otherwise. 304 / Not Modified responses return None.

Parameters:

Name Type Description Default
url str

URL to download from

required
destination str | Path

where to save the file to

required
expected_checksum str | None

expected checksum (if known), defaults to None

None
checksum_fn str | None

function used for calculating the checksum, defaults to "sha256"

'sha256'
extra_headers dict[str, Any] | None

allow extra headers to be passed, defaults to None

None

Returns:

Type Description
Path | None

either the path to the downloaded file or None if no file was downloaded

Source code in src/cdm_data_loaders/utils/file_transfer/async_client.py
async def download(
    self,
    url: str,
    destination: str | Path,
    expected_checksum: str | None = None,
    checksum_fn: str | None = "sha256",
    extra_headers: dict[str, Any] | None = None,
) -> Path | None:
    """Download a file from ``url`` to ``destination`` on disk.

    Returns the file path if successful; None otherwise.
    304 / Not Modified responses return None.

    :param url: URL to download from
    :type url: str
    :param destination: where to save the file to
    :type destination: str | Path
    :param expected_checksum: expected checksum (if known), defaults to None
    :type expected_checksum: str | None, optional
    :param checksum_fn: function used for calculating the checksum, defaults to "sha256"
    :type checksum_fn: str, optional
    :param extra_headers: allow extra headers to be passed, defaults to None
    :type extra_headers: dict[str, Any] | None, optional
    :return: either the path to the downloaded file or None if no file was downloaded
    :rtype: Path | None
    """
    destination, checksum_fn, extra_headers = DownloadCore.validate_args(destination, checksum_fn, extra_headers)

    async with self.semaphore or contextlib.nullcontext():
        return await self._download_with_retry(
            url,
            destination,
            expected_checksum,
            checksum_fn,
            extra_headers,
        )

get_async_httpx_client

get_async_httpx_client() -> httpx.AsyncClient

Get a basic client for executing http requests.

Source code in src/cdm_data_loaders/utils/file_transfer/async_client.py
def get_async_httpx_client() -> httpx.AsyncClient:
    """Get a basic client for executing http requests."""
    return httpx.AsyncClient(**HTTP_CLIENT_DEFAULTS)