Skip to content

cdm_data_loaders.utils.file_transfer.core

File downloader with tenacity for retry support.

DownloadError

Bases: Exception

Retryable download error.

Source code in src/cdm_data_loaders/utils/file_transfer/core.py
class DownloadError(Exception):
    """Retryable download error."""

NonRetryableDownloadError

Bases: Exception

Retryable download error.

Source code in src/cdm_data_loaders/utils/file_transfer/core.py
class NonRetryableDownloadError(Exception):
    """Retryable download error."""

ChecksumMismatchError

Bases: NonRetryableDownloadError

Checksum validation failed.

Source code in src/cdm_data_loaders/utils/file_transfer/core.py
class ChecksumMismatchError(NonRetryableDownloadError):
    """Checksum validation failed."""

DownloadCore

HTTP file downloader with retries and checksum testing.

Source code in src/cdm_data_loaders/utils/file_transfer/core.py
class DownloadCore:
    """HTTP file downloader with retries and checksum testing."""

    RETRYABLE_EXCEPTIONS = (
        httpx.TimeoutException,
        httpx.TransportError,
        DownloadError,
    )

    @staticmethod
    def retry_kwargs(max_attempts: int, min_backoff: int, max_backoff: int) -> dict[str, Any]:
        """Build common tenacity retry configuration for sync and async downloaders."""
        return {
            "retry": retry_if_exception_type(DownloadCore.RETRYABLE_EXCEPTIONS),
            "stop": stop_after_attempt(max_attempts),
            "wait": wait_exponential(min=min_backoff, max=max_backoff),
            "reraise": True,
        }

    @staticmethod
    def handle_download_exception(exc: Exception, url: str, log: Logger) -> Never:
        """Log a download failure and re-raise or wrap it in ``DownloadError``."""
        if isinstance(exc, NonRetryableDownloadError):
            log.exception("%s: %s; retry not possible", url, exc.args[0], extra={"url": url})  # noqa: LOG004
            raise exc
        log.exception("%s: %s; retry possible", url, exc.args[0], extra={"url": url})  # noqa: LOG004
        raise DownloadError(str(exc)) from exc

    @staticmethod
    def validate_args(
        destination: str | Path,
        checksum_fn: str | None = None,
        extra_headers: dict[str, Any] | None = None,
    ) -> tuple[Path, str, dict[str, Any]]:
        """Validate download arguments, apply defaults, and convert them to the appropriate format.

        If no checksum function is supplied, it will be set to "sha256".

        :param destination: location to save file to
        :type destination: str | Path
        :param checksum_fn: function for calculating file checksum
        :type checksum_fn: str | None, optional
        :param extra_headers: extra headers to add to the httpx request, defaults to None
        :type extra_headers: dict[str, Any] | None, optional
        :return: arguments spruced up and in shape!
        :rtype: tuple[Path, str, dict[str, Any]]
        """
        if not checksum_fn:
            checksum_fn = "sha256"

        validated_checksum_fn = validate_checksum_fn(checksum_fn, include_extras=False)
        destination = Path(destination)
        destination.parent.mkdir(parents=True, exist_ok=True)

        if not extra_headers:
            extra_headers = {}

        return (destination, validated_checksum_fn, extra_headers)

    @staticmethod
    def validate_response(response: httpx.Response) -> bool:
        """Check the response from the server and act accordingly."""
        status = response.status_code

        # not modified since last accessed
        if status == 304:  # noqa: PLR2004
            return False

        # successful response
        if 200 <= status < 300:  # noqa: PLR2004
            return True

        # client error
        if 400 <= status < 500:  # noqa: PLR2004
            msg = f"Client error: {status} {response.reason_phrase}"
            raise NonRetryableDownloadError(msg)

        # server errors
        msg = f"Server error: {status} {response.reason_phrase}"
        raise DownloadError(msg)

    @staticmethod
    def check_should_download(response: httpx.Response, url: str, log: Logger) -> bool:
        """Validate ``response`` and log when the resource is unmodified (HTTP 304).

        :param response: HTTP response from the server
        :type response: httpx.Response
        :param url: download URL
        :type url: str
        :param log: logger instance
        :type log: Logger
        :return: ``True`` if the response body should be downloaded, ``False`` on HTTP 304
        :rtype: bool
        """
        if not DownloadCore.validate_response(response):
            log.info("%s: resource has not been modified", url, extra={"url": url})
            return False
        return True

    @staticmethod
    def log_download_success(url: str, destination: Path, log: Logger) -> Path:
        """Log a successful download and return ``destination``.

        :param url: download URL
        :type url: str
        :param destination: saved file path
        :type destination: Path
        :param log: logger instance
        :type log: Logger
        :return: ``destination``
        :rtype: Path
        """
        log.info("%s: download successful", url, extra={"path": str(destination)})
        return destination

    @staticmethod
    def check_hash(
        url: str,
        expected_checksum: str,
        checksum_fn: str,
        hash_obj,  #: hashlib._Hash,
    ) -> None:
        """Compare the expected to actual checksum.

        :param url: download URL
        :type url: str
        :param expected_checksum: expected checksum
        :type expected_checksum: str
        :param checksum_fn: checksum algorithm
        :type checksum_fn: str
        :param hash_obj: hash object for calculating checksum
        :type hash_obj: bool
        :raises ChecksumMismatchError: if the checksum is incorrect
        """
        if hash_obj:
            # check the checksum matches expectations
            actual = hash_obj.hexdigest()
            if actual.lower() != expected_checksum.lower():
                msg = f"{url}: Checksum mismatch: expected={expected_checksum}, actual={actual}"
                raise ChecksumMismatchError(msg)
            logger.info("%s: %s checksum matches", url, checksum_fn)

    @staticmethod
    @contextmanager
    def open_hash_writer(
        url: str,
        destination: Path,
        expected_checksum: str | None,
        checksum_fn: str,
    ) -> Generator[Callable[[bytes], None]]:
        """Open ``destination`` for binary writing, update an optional hasher per chunk, and verify on exit.

        :param url: download URL
        :type url: str
        :param destination: file path to write to
        :type destination: Path
        :param expected_checksum: expected checksum (if known)
        :type expected_checksum: str | None
        :param checksum_fn: checksum algorithm name
        :type checksum_fn: str
        :yield: callback accepting a ``bytes`` chunk to write and hash
        :rtype: Generator[Callable[[bytes], None]]
        """
        hash_obj = new_hasher(checksum_fn) if expected_checksum else None
        with destination.open("wb") as fh:

            def _write_chunk(chunk: bytes) -> None:
                fh.write(chunk)
                if hash_obj:
                    hash_obj.update(chunk)

            yield _write_chunk

        if hash_obj and expected_checksum:
            DownloadCore.check_hash(url, expected_checksum, checksum_fn, hash_obj)

    @staticmethod
    def write_and_hash(
        url: str,
        destination: Path,
        chunks: Iterable[bytes],
        expected_checksum: str | None,
        checksum_fn: str,
    ) -> None:
        """Write downloaded data to disk and accrue data for calculating the checksum of the download.

        :param url: URL to download from
        :type url: str
        :param destination: where to save the file to
        :type destination: Path
        :param chunks: iterable chunks of downloaded data
        :type chunks: Iterable[bytes]
        :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
        :raises ChecksumMismatchError: if the checksum of the downloaded data does not match the expected checksum.
        """
        with DownloadCore.open_hash_writer(url, destination, expected_checksum, checksum_fn) as write_chunk:
            for chunk in chunks:
                write_chunk(chunk)

    @staticmethod
    async def write_and_hash_async(
        url: str,
        destination: Path,
        chunks: AsyncIterable[bytes],
        expected_checksum: str | None,
        checksum_fn: str,
    ) -> None:
        """Write downloaded data to disk and accrue data for calculating the checksum of the download.

        :param url: URL to download from
        :type url: str
        :param destination: where to save the file to
        :type destination: Path
        :param chunks: iterable chunks of downloaded data
        :type chunks: AsyncIterable[bytes]
        :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
        :raises ChecksumMismatchError: if the checksum of the downloaded data does not match the expected checksum.
        """
        with DownloadCore.open_hash_writer(url, destination, expected_checksum, checksum_fn) as write_chunk:
            async for chunk in chunks:
                write_chunk(chunk)

retry_kwargs staticmethod

retry_kwargs(
    max_attempts: int, min_backoff: int, max_backoff: int
) -> dict[str, Any]

Build common tenacity retry configuration for sync and async downloaders.

Source code in src/cdm_data_loaders/utils/file_transfer/core.py
@staticmethod
def retry_kwargs(max_attempts: int, min_backoff: int, max_backoff: int) -> dict[str, Any]:
    """Build common tenacity retry configuration for sync and async downloaders."""
    return {
        "retry": retry_if_exception_type(DownloadCore.RETRYABLE_EXCEPTIONS),
        "stop": stop_after_attempt(max_attempts),
        "wait": wait_exponential(min=min_backoff, max=max_backoff),
        "reraise": True,
    }

handle_download_exception staticmethod

handle_download_exception(
    exc: Exception, url: str, log: Logger
) -> Never

Log a download failure and re-raise or wrap it in DownloadError.

Source code in src/cdm_data_loaders/utils/file_transfer/core.py
@staticmethod
def handle_download_exception(exc: Exception, url: str, log: Logger) -> Never:
    """Log a download failure and re-raise or wrap it in ``DownloadError``."""
    if isinstance(exc, NonRetryableDownloadError):
        log.exception("%s: %s; retry not possible", url, exc.args[0], extra={"url": url})  # noqa: LOG004
        raise exc
    log.exception("%s: %s; retry possible", url, exc.args[0], extra={"url": url})  # noqa: LOG004
    raise DownloadError(str(exc)) from exc

validate_args staticmethod

validate_args(
    destination: str | Path,
    checksum_fn: str | None = None,
    extra_headers: dict[str, Any] | None = None,
) -> tuple[Path, str, dict[str, Any]]

Validate download arguments, apply defaults, and convert them to the appropriate format.

If no checksum function is supplied, it will be set to "sha256".

Parameters:

Name Type Description Default
destination str | Path

location to save file to

required
checksum_fn str | None

function for calculating file checksum

None
extra_headers dict[str, Any] | None

extra headers to add to the httpx request, defaults to None

None

Returns:

Type Description
tuple[Path, str, dict[str, Any]]

arguments spruced up and in shape!

Source code in src/cdm_data_loaders/utils/file_transfer/core.py
@staticmethod
def validate_args(
    destination: str | Path,
    checksum_fn: str | None = None,
    extra_headers: dict[str, Any] | None = None,
) -> tuple[Path, str, dict[str, Any]]:
    """Validate download arguments, apply defaults, and convert them to the appropriate format.

    If no checksum function is supplied, it will be set to "sha256".

    :param destination: location to save file to
    :type destination: str | Path
    :param checksum_fn: function for calculating file checksum
    :type checksum_fn: str | None, optional
    :param extra_headers: extra headers to add to the httpx request, defaults to None
    :type extra_headers: dict[str, Any] | None, optional
    :return: arguments spruced up and in shape!
    :rtype: tuple[Path, str, dict[str, Any]]
    """
    if not checksum_fn:
        checksum_fn = "sha256"

    validated_checksum_fn = validate_checksum_fn(checksum_fn, include_extras=False)
    destination = Path(destination)
    destination.parent.mkdir(parents=True, exist_ok=True)

    if not extra_headers:
        extra_headers = {}

    return (destination, validated_checksum_fn, extra_headers)

validate_response staticmethod

validate_response(response: Response) -> bool

Check the response from the server and act accordingly.

Source code in src/cdm_data_loaders/utils/file_transfer/core.py
@staticmethod
def validate_response(response: httpx.Response) -> bool:
    """Check the response from the server and act accordingly."""
    status = response.status_code

    # not modified since last accessed
    if status == 304:  # noqa: PLR2004
        return False

    # successful response
    if 200 <= status < 300:  # noqa: PLR2004
        return True

    # client error
    if 400 <= status < 500:  # noqa: PLR2004
        msg = f"Client error: {status} {response.reason_phrase}"
        raise NonRetryableDownloadError(msg)

    # server errors
    msg = f"Server error: {status} {response.reason_phrase}"
    raise DownloadError(msg)

check_should_download staticmethod

check_should_download(
    response: Response, url: str, log: Logger
) -> bool

Validate response and log when the resource is unmodified (HTTP 304).

Parameters:

Name Type Description Default
response Response

HTTP response from the server

required
url str

download URL

required
log Logger

logger instance

required

Returns:

Type Description
bool

True if the response body should be downloaded, False on HTTP 304

Source code in src/cdm_data_loaders/utils/file_transfer/core.py
@staticmethod
def check_should_download(response: httpx.Response, url: str, log: Logger) -> bool:
    """Validate ``response`` and log when the resource is unmodified (HTTP 304).

    :param response: HTTP response from the server
    :type response: httpx.Response
    :param url: download URL
    :type url: str
    :param log: logger instance
    :type log: Logger
    :return: ``True`` if the response body should be downloaded, ``False`` on HTTP 304
    :rtype: bool
    """
    if not DownloadCore.validate_response(response):
        log.info("%s: resource has not been modified", url, extra={"url": url})
        return False
    return True

log_download_success staticmethod

log_download_success(
    url: str, destination: Path, log: Logger
) -> Path

Log a successful download and return destination.

Parameters:

Name Type Description Default
url str

download URL

required
destination Path

saved file path

required
log Logger

logger instance

required

Returns:

Type Description
Path

destination

Source code in src/cdm_data_loaders/utils/file_transfer/core.py
@staticmethod
def log_download_success(url: str, destination: Path, log: Logger) -> Path:
    """Log a successful download and return ``destination``.

    :param url: download URL
    :type url: str
    :param destination: saved file path
    :type destination: Path
    :param log: logger instance
    :type log: Logger
    :return: ``destination``
    :rtype: Path
    """
    log.info("%s: download successful", url, extra={"path": str(destination)})
    return destination

check_hash staticmethod

check_hash(
    url: str,
    expected_checksum: str,
    checksum_fn: str,
    hash_obj,
) -> None

Compare the expected to actual checksum.

Parameters:

Name Type Description Default
url str

download URL

required
expected_checksum str

expected checksum

required
checksum_fn str

checksum algorithm

required
hash_obj bool

hash object for calculating checksum

required

Raises:

Type Description
ChecksumMismatchError

if the checksum is incorrect

Source code in src/cdm_data_loaders/utils/file_transfer/core.py
@staticmethod
def check_hash(
    url: str,
    expected_checksum: str,
    checksum_fn: str,
    hash_obj,  #: hashlib._Hash,
) -> None:
    """Compare the expected to actual checksum.

    :param url: download URL
    :type url: str
    :param expected_checksum: expected checksum
    :type expected_checksum: str
    :param checksum_fn: checksum algorithm
    :type checksum_fn: str
    :param hash_obj: hash object for calculating checksum
    :type hash_obj: bool
    :raises ChecksumMismatchError: if the checksum is incorrect
    """
    if hash_obj:
        # check the checksum matches expectations
        actual = hash_obj.hexdigest()
        if actual.lower() != expected_checksum.lower():
            msg = f"{url}: Checksum mismatch: expected={expected_checksum}, actual={actual}"
            raise ChecksumMismatchError(msg)
        logger.info("%s: %s checksum matches", url, checksum_fn)

open_hash_writer staticmethod

open_hash_writer(
    url: str,
    destination: Path,
    expected_checksum: str | None,
    checksum_fn: str,
) -> Generator[Callable[[bytes], None]]

Open destination for binary writing, update an optional hasher per chunk, and verify on exit.

:yield: callback accepting a bytes chunk to write and hash

Parameters:

Name Type Description Default
url str

download URL

required
destination Path

file path to write to

required
expected_checksum str | None

expected checksum (if known)

required
checksum_fn str

checksum algorithm name

required
Source code in src/cdm_data_loaders/utils/file_transfer/core.py
@staticmethod
@contextmanager
def open_hash_writer(
    url: str,
    destination: Path,
    expected_checksum: str | None,
    checksum_fn: str,
) -> Generator[Callable[[bytes], None]]:
    """Open ``destination`` for binary writing, update an optional hasher per chunk, and verify on exit.

    :param url: download URL
    :type url: str
    :param destination: file path to write to
    :type destination: Path
    :param expected_checksum: expected checksum (if known)
    :type expected_checksum: str | None
    :param checksum_fn: checksum algorithm name
    :type checksum_fn: str
    :yield: callback accepting a ``bytes`` chunk to write and hash
    :rtype: Generator[Callable[[bytes], None]]
    """
    hash_obj = new_hasher(checksum_fn) if expected_checksum else None
    with destination.open("wb") as fh:

        def _write_chunk(chunk: bytes) -> None:
            fh.write(chunk)
            if hash_obj:
                hash_obj.update(chunk)

        yield _write_chunk

    if hash_obj and expected_checksum:
        DownloadCore.check_hash(url, expected_checksum, checksum_fn, hash_obj)

write_and_hash staticmethod

write_and_hash(
    url: str,
    destination: Path,
    chunks: Iterable[bytes],
    expected_checksum: str | None,
    checksum_fn: str,
) -> None

Write downloaded data to disk and accrue data for calculating the checksum of the download.

Parameters:

Name Type Description Default
url str

URL to download from

required
destination Path

where to save the file to

required
chunks Iterable[bytes]

iterable chunks of downloaded data

required
expected_checksum str | None

expected checksum (if known), defaults to None

required
checksum_fn str

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

required

Raises:

Type Description
ChecksumMismatchError

if the checksum of the downloaded data does not match the expected checksum.

Source code in src/cdm_data_loaders/utils/file_transfer/core.py
@staticmethod
def write_and_hash(
    url: str,
    destination: Path,
    chunks: Iterable[bytes],
    expected_checksum: str | None,
    checksum_fn: str,
) -> None:
    """Write downloaded data to disk and accrue data for calculating the checksum of the download.

    :param url: URL to download from
    :type url: str
    :param destination: where to save the file to
    :type destination: Path
    :param chunks: iterable chunks of downloaded data
    :type chunks: Iterable[bytes]
    :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
    :raises ChecksumMismatchError: if the checksum of the downloaded data does not match the expected checksum.
    """
    with DownloadCore.open_hash_writer(url, destination, expected_checksum, checksum_fn) as write_chunk:
        for chunk in chunks:
            write_chunk(chunk)

write_and_hash_async async staticmethod

write_and_hash_async(
    url: str,
    destination: Path,
    chunks: AsyncIterable[bytes],
    expected_checksum: str | None,
    checksum_fn: str,
) -> None

Write downloaded data to disk and accrue data for calculating the checksum of the download.

Parameters:

Name Type Description Default
url str

URL to download from

required
destination Path

where to save the file to

required
chunks AsyncIterable[bytes]

iterable chunks of downloaded data

required
expected_checksum str | None

expected checksum (if known), defaults to None

required
checksum_fn str

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

required

Raises:

Type Description
ChecksumMismatchError

if the checksum of the downloaded data does not match the expected checksum.

Source code in src/cdm_data_loaders/utils/file_transfer/core.py
@staticmethod
async def write_and_hash_async(
    url: str,
    destination: Path,
    chunks: AsyncIterable[bytes],
    expected_checksum: str | None,
    checksum_fn: str,
) -> None:
    """Write downloaded data to disk and accrue data for calculating the checksum of the download.

    :param url: URL to download from
    :type url: str
    :param destination: where to save the file to
    :type destination: Path
    :param chunks: iterable chunks of downloaded data
    :type chunks: AsyncIterable[bytes]
    :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
    :raises ChecksumMismatchError: if the checksum of the downloaded data does not match the expected checksum.
    """
    with DownloadCore.open_hash_writer(url, destination, expected_checksum, checksum_fn) as write_chunk:
        async for chunk in chunks:
            write_chunk(chunk)

BaseFileDownloader

Shared base class for synchronous and asynchronous HTTP file downloaders.

Source code in src/cdm_data_loaders/utils/file_transfer/core.py
class BaseFileDownloader:
    """Shared base class for synchronous and asynchronous HTTP file downloaders."""

    RETRYABLE_EXCEPTIONS = DownloadCore.RETRYABLE_EXCEPTIONS

    def __init__(self, client: Any, *, chunk_size: int = 8192) -> None:  # noqa: ANN401
        """Initialize shared HTTP client and chunk size attributes.

        :param client: configured sync or async httpx client
        :type client: Any
        :param chunk_size: download stream chunk size in bytes
        :type chunk_size: int
        """
        self.client = client
        self.chunk_size = chunk_size

__init__

__init__(client: Any, *, chunk_size: int = 8192) -> None

Initialize shared HTTP client and chunk size attributes.

Parameters:

Name Type Description Default
client Any

configured sync or async httpx client

required
chunk_size int

download stream chunk size in bytes

8192
Source code in src/cdm_data_loaders/utils/file_transfer/core.py
def __init__(self, client: Any, *, chunk_size: int = 8192) -> None:  # noqa: ANN401
    """Initialize shared HTTP client and chunk size attributes.

    :param client: configured sync or async httpx client
    :type client: Any
    :param chunk_size: download stream chunk size in bytes
    :type chunk_size: int
    """
    self.client = client
    self.chunk_size = chunk_size